RAM usage

Hello,

We are experimenting with “big data” and GroupDocs.Search.

Putting 10k files into the index works, putting 100k files into it works too (because of the recent ArithmeticOverflow fix), but indexing 1 million realistic files seems to be impossible for now.

We have extraction and adding bytes to the index + calling optimize separated. The extraction happens in a different process. What we experience is: When adding stuff to the index over hours, the memory usage of that process becomes higher and higher (passing 10 GB of RAM, then 20 GB)… And at some points, it exceeds the 32 GB of RAM that our system has and there is an OutOfMemoryException:

"System.OutOfMemoryException: Exception of type ‘System.OutOfMemoryException’ was thrown.\r\n at System.Runtime.CompilerServices.RuntimeHelpers.AllocateUninitializedClone(ObjectHandleOnStack objHandle)\r\n at System.Runtime.CompilerServices.RuntimeHelpers.AllocateUninitializedClone(ObjectHandleOnStack objHandle)\r\n at \u0005\u0010\u0002.\u0002()\r\n at \u0006\u0002\u001B.\u0002(\u0006\u0002\u001B \u0002)\r\n at \u0006\u0003\u0002.\u0002()\r\n at \u0006\u0003\u0002.\u0002(\u0006\u0019\u0003 \u0002, \u0006\u0015\u0003 \u0008, \u0006\u0018\u0002 \u0005, \u0002\u0002\u0017 \u0006)\r\n at \u0005\u0003\u001B.\u0002(Boolean \u0002, Int32 \u0008, \u0006\u0019\u0003 \u0005, \u0006\u0015\u0003 \u0006, \u0006\u0018\u0002 \u0003, OperationType \u000E)

Even when stopping the process and restarting it again… When the indexes are freshly loaded from disk, the private bytes of the process grow to 10 - 20 GB again within a short amount of time.

Questions:

  1. Is there a way to consume a GroupDocs index with less of it being loading into the RAM (e.g. a tradeoff option in the settings… Slower search, but less space…)
  2. Is the full stuff from disk (the index folder) loaded into the RAM in general?
  3. Is it even possible to put 1 million files into an index? Have you ever tried it?
  4. Is there an estimation how much RAM one needs for an index that is 25 GB on disk? Is it 1:1? A GB of RAM for each GB on disk…
  5. What could we do to not have to ask our customers to “just have 100 GB of RAM”? And what could be done from your side?

Hi, @jamsharp!

Thanks for the detailed report. We did a deep dive into the investigation, so let me share all aspects.
The stack trace was enough to locate the exact call site, so let me go through your questions one by one.

The short version of what your stack trace says

RunIndexing(...)                      // the Add/Update entry point
  -> StartIndexing(...)
    -> FinishIndexing()               // the commit, after all documents are processed
      -> IndexInfoContainer.CopyFrom(IndexInfoContainer)
        -> MultiArrayTree.Clone()
          -> array clone -> OutOfMemoryException

So you did not run out of memory while extracting or while adding documents. You ran out at the commit of an indexing operation, while the index metadata and the term dictionary are being deep-copied. That is important, because it means the fix on your side is mostly about how often you commit, not about how much text you push.


We reproduced it

Before answering, we built a small GitHub benchmark repo solution that indexes the same documents three different ways and reports what each costs. We used the Digital Corpora document set to run this.

Sixty mixed-format documents (HTML, PDF, TXT, DOC/DOCX, XLS/XLSX, PPT/PPTX), GroupDocs.Search 26.8.0, .NET 8, Release build, x64, workstation GC, Threads = 1. All three runs indexed a byte-identical document set (verified by hash) and returned identical search results, so nothing is traded away:

one Add() per document one Add() per batch of 60 3 shards of 20
Add() calls 60 1 3
Total allocated 91.22 GB 3.74 GB 6.84 GB
Allocated per document 1.52 GB 63.9 MB 116.8 MB
Peak private bytes 8.77 GB 1.80 GB 4.28 GB
Retained managed heap 68.0 MB 68.0 MB 46.3 MB
Retained LOH 126.7 MB 1.43 GB 174.5 MB

How to read the table

There are four memory rows because “memory” means four different things here, and they move independently. Conflating them is what makes this problem so confusing to diagnose:

Row What it measures Why it is here
Total allocated Every byte the process ever asked the allocator for, cumulative (GC.GetTotalAllocatedBytes). Memory that was freed still counts. The only way to see a 1.4 GB buffer that is allocated and collected inside a single Add() call. It never appears in a heap snapshot, yet it is what drives GC pressure and LOH churn.
Allocated per document Total allocated ÷ 60. Makes the three approaches directly comparable, since all three index the same 60 files.
Peak private High-water mark of Process.PrivateMemorySize64, sampled by a background thread every 25 ms. This is what your monitoring graphs and what hit 32 GB on your box. Sampling it on a timer rather than at loop boundaries matters — we got that wrong in our first run, and it flattered the batched result.
Retained managed Managed heap still alive after a forced blocking collection at the end of the run. What an open index genuinely holds. Reading the heap without forcing a collection just measures uncollected garbage.
Retained LOH Large Object Heap size after that same forced collection. The LOH is not compacted by default and is not decommitted eagerly, so this is the part of your footprint that fragments and does not come back.

All figures are build-phase only — index construction and Add() calls, nothing else. Sample 03’s repository load and cross-shard search are measured separately and excluded, because samples 01 and 02 have no equivalent step.

What the three columns tell you

Column 1 reproduces your symptom with sixty documents. The per-document run peaked at 8.77 GB of private bytes while retaining only 68 MB of managed heap once collected — a factor of 129 between what the process held from the operating system and what it was genuinely using. That gap is LOH churn and fragmentation, and it is exactly the “private bytes keep climbing while the application isn’t really holding anything” behaviour you described. It took 60 files, not a million.

Column 2 is the fix, and it is almost embarrassingly cheap. Same documents, same results, one Add() call instead of sixty: 24× less allocation and 4.9× lower peak private bytes. The only code change is passing a bigger array.

Column 3 shows what batching does not fix. Note that retained managed heap is identical between columns 1 and 2 — 68.0 MB either way. Batching does nothing for what an open index holds. Only closing indexes does, and the effect shows up in the LOH row rather than the managed heap row.

One thing we want to be straight about: our first pass at this table read the managed heap without forcing a collection, and we briefly concluded that sharding cut resident memory 7.7×. It does not — that was uncollected garbage, not retention. The corrected figures are above, and the real sharding win turned out to be in the LOH instead. We mention it because you should treat any memory number you take from us, or measure yourself, with that same suspicion.

Fitting the first two runs gives a model that predicts the third within 2.0%:

total allocated  ~  1.48 GB x (number of Add() calls)  +  ~39 MB x (documents)

That first term is the whole story. In the per-document run, 97.6% of all allocation was fixed per-call overhead that had nothing to do with the documents. And 1.48 GB measured lines up within 6% with the 1.43 GB we calculate from the source for the term-collector buffers - so the model isn’t a curve fit, it’s the mechanism.

The practical consequence is a table you can read your batch size off directly:

documents per Add() call fixed overhead per document
1 1518 MB
20 76 MB
100 15 MB
1 000 1.5 MB
10 000 0.15 MB

And there is a second lever, in the Large Object Heap. Look at the last row. The single 60-document batch leaves 1.43 GB of LOH standing after a forced collection, because the term-collector buffers are LOH-sized and the runtime does not decommit them eagerly. Building the same documents as three disposable shards holds that to 174.5 MB - an 8.4x difference. Since the LOH is exactly what fragments and pushes a long-running process’s private bytes up, that is the number that decides whether your indexing service survives a multi-hour run. Batching fixes your allocation rate; disposing indexes fixes your LOH footprint. You need both.

Run it yourself

The benchmark is a small .NET 8 solution — three console projects sharing one library — published as document-indexing-memory-optimization-net. Point it at a folder of your own documents, set your licence path in an environment variable, and it prints the same table:

export GROUPDOCS_LIC_PATH=/path/to/your.lic
export INDEXING_LAB_STORAGE_ROOT=/path/to/your/documents
export INDEXING_LAB_MAX_DOCUMENTS=60

dotnet run -c Release --project samples/Sample01.PerDocument
dotnet run -c Release --project samples/Sample02.Batched
INDEXING_LAB_DOCS_PER_SHARD=20 dotnet run -c Release --project samples/Sample03.Sharded

Each run writes a CSV and a JSON manifest recording the document-set hash, licence state, GC mode, build configuration and search results, so two runs can be compared rather than merely asserted to be comparable. The raw data behind the table above ships in the repository under results/published/.


Following your questions

1. Is there a way to consume an index with less of it loaded into RAM (a trade-off setting — slower search, less memory)?

Not today. There is no such switch, and I’d rather tell you that straight than point you at an option that won’t help.

Here is what the current design does, so you can see where the ceiling comes from:

  • Postings (the inverted index itself) — the index{N}.body / index{N}.head files — are streamed from disk. They are never fully loaded. This is the bulk of your 25 GB, and it is not your problem.
  • The term dictionary (index1.info) is loaded completely into RAM at index open. On disk it is Huffman-encoded; in memory it is expanded into raw 16 MB byte arrays at 7 bytes per trie node. Expect roughly 2–4× the file size in RAM.
  • Per-document metadata (index.info) is loaded completely into RAM as ~10 hash tables: path → id, id → path, document → elements, element → (document, field), term counts, encodings, container items, attributes. This scales linearly with document count and has no disk fallback.
  • Stored text (if you enabled text storage for highlighting) stays on disk. In-memory text storage is only used for in-memory indexes.

So the answer to “can I trade speed for memory” is: the parts that could be traded are already on disk; the parts that are in RAM are in RAM unconditionally. Making the metadata and the term dictionary memory-mappable is exactly the change we need to make, and your report is the concrete case for prioritising it. I’ll come back to that at the end.

2. Is the full contents of the index folder loaded into RAM?

No — but the distinction matters less than you’d hope.

File Loaded at open? Notes
index{N}.body, index{N}.head No — streamed Usually >90% of the folder size
index1.info (term dictionary) Yes, fully Expands 2–4× over the file size
index.info (document metadata) Yes, fully ~10 hash tables, linear in document count
index0.info (image hashes) Yes, fully Small unless you index images
Text archive No — streamed Only if text storage is enabled

If you want to know your own split without guessing: post the sizes of index.info, index1.info and index0.info. Those three numbers tell us your resident footprint almost exactly.

3. Is it even possible to put 1 million files into one index? Have you tried it?

Yes, even on 32 GB, the same as your machine. We have now built it, in a single index, using the Digital Corpora dataset:

Source corpus 986,262 files, 466 GB
Documents indexed 969,288
Extracted text 91.2 GB
Resulting index 440 GB across 740 segments
Machine 32 GB RAM
Outcome completed, no errors

The approach that made it work was batching. Nothing exotic: no sharding, no separate processes, no special GC configuration — one index, documents fed in batches rather than one at a time.

So the honest answer to your question has changed since we started looking at this: the ceiling is not where you are hitting it. A million files in one index is fine on 32 GB. What is not fine is calling Add() once per document, because that pays the ~1.48 GB fixed cost 969,288 times instead of a few hundred.

Three details worth having:

  • 969,288 of 986,262 were indexed — about 1.7% were skipped as corrupt, encrypted, or an unsupported format. That is normal for a corpus assembled from the wild, and it is not a memory problem. Count your own skips before assuming something went wrong.

  • **Our index is 440 GB ** Ours is roughly seventeen times larger and still built inside 32 GB.

  • 740 segments. Segment count grows with the number of indexing operations, and every search consults every segment. Keep an eye on it and run Optimize() periodically — but do it as a deliberate, occasional maintenance step, not after every batch, since merging takes the same expensive deep-copy path as indexing.

This also revises something we said earlier in this thread: we had guessed that resident metadata would make one large index impractical and that sharding was effectively mandatory. The run above shows the resident structures for ~1M documents sit comfortably within 32 GB. Sharding is still worth doing — it keeps the Large Object Heap small, which matters for a service that indexes continuously for days — but it is an optimization, not a prerequisite. You do not need to redesign around it to get to a million files.

4. Is there an estimate of how much RAM an index needs? Is it 1:1 with disk?

It is not proportional to total disk size — total disk size is dominated by the postings, which are not resident. Two different numbers matter here, and conflating them is what makes this confusing.

Resident memory — what an open index holds for as long as it is open:

steady-state RAM  ~  (documents + container items) x ~0.8 KB      // metadata hash tables
                   + 2..4 x sizeof(index1.info)                    // decoded term dictionary

Allocation during an indexing or optimize operation — measured, not estimated:

allocated  ~  1.48 GB x (number of Add() calls)  +  ~37 MB x (documents)

plus, at the moment of commit, a transient peak of roughly 3 x metadata + 2 x term dictionary — the deep copies and the tree clone from your stack trace. That transient is negligible on a small index and dominant on yours, because it scales with the index you already have rather than with the batch you are adding. At 1M documents it is gigabytes, which is why the clone is where you actually died.

The 1.48 GB fixed term is the term collector, which preallocates fixed-size buffers up front regardless of how many documents you are adding. Here is what we calculate from the source — and the total agrees with the measured fixed cost within 6%:

Buffer NormalIndex CompactIndex
positions 1114 MB 572 MB
term dictionary (pre-sized) 173 MB 788 MB
other index arrays + index data ~143 MB ~569 MB
total, per indexing thread ~1.43 GB ~1.93 GB

Adding one document allocates the full ~1.43 GB. Adding 10,000 documents in a single call allocates the same ~1.43 GB. With IndexingOptions.Threads = 4 you get one of these per thread, so ~5.7 GB.

Two consequences worth internalising:

  • Small batches are catastrophically expensive. The cost is per Add(...) call, not per document. If you are adding documents one at a time or in batches of 100, you are paying 1.43 GB of allocation plus two full deep copies of your entire index metadata per batch.
  • CompactIndex costs more RAM, not less. It saves disk space, but its collector is ~500 MB larger. If you picked it hoping to save memory, switch back.

And this is why the process never gives the memory back: essentially all of these allocations are ≥ 85 KB, so they land on the Large Object Heap. The LOH is not compacted by default, so thousands of allocate/free cycles over several hours fragment it badly. Private bytes climb, the managed heap looks fine, and eventually a 16 MB contiguous request fails on a machine that still reports free memory. That is exactly the failure mode in your trace.

5. What can you do so you don’t have to tell customers “just buy 100 GB of RAM” — and what will we do?

What you can do today, in rough order of impact

  1. Batch much harder. Use index.Add(ExtractedData[], IndexingOptions) or index.Add(Document[], IndexingOptions) with 5,000–20,000 documents per call. This is by far the biggest lever: in our benchmark it cut total allocation 24.4× and peak private bytes 4.9×, with identical search results. Read your target off the amortisation table above — at 10,000 documents per call the fixed overhead falls to 0.15 MB per document, from 1518 MB at one document per call.

  2. Keep IndexingOptions.Threads = 1 (that is the default — verify you haven’t raised it). Each extra thread is another ~1.48 GB. Since you already extract in a separate process, indexing threads buy you very little here.

  3. Build in shards you dispose, and query them with IndexRepository. Build indexes of ~100k documents each, closing each one before starting the next, then add them to a repository and search across all of them. In our benchmark this held the retained Large Object Heap to 174.5 MB against 1.43 GB for a single open index — an 8.4× difference. Because the LOH is what fragments and drives private bytes upward over hours, this is the lever that decides whether a long-running indexing service stays healthy.

    Two honest caveats. First, sharding does not reduce allocation — our 3-shard run allocated more than the single batch (6.84 GB vs 3.74 GB), because each shard re-pays the same 1.48 GB fixed cost. Twenty documents per shard was deliberately extreme to force three shards out of sixty; at 100k per shard that cost is noise. Second, the managed heap barely differs (68 MB with one index open vs 46 MB sharded) — the win is specifically in the LOH, not in ordinary object retention. Batch for allocation rate, dispose indexes for LOH footprint, and do both.

  4. Tune the GC for LOH fragmentation in the indexing process. On .NET Framework:

    <configuration>
      <runtime>
        <gcServer enabled="true"/>
        <gcAllowVeryLargeObjects enabled="true"/>
      </runtime>
    </configuration>
    

    On .NET (Core) 6+: <ServerGarbageCollection>true</ServerGarbageCollection>, <ConcurrentGarbageCollection>false</ConcurrentGarbageCollection>. And between batches:

    GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce;
    GC.Collect();
    

    This won’t fix the root cause, but it will stop private bytes from ratcheting upward across a long run.

  5. Run on .NET 8+ rather than .NET Framework if you have the option — noticeably better behaviour for this allocation pattern.

  6. Set IndexSettings.MaxIndexingReportCount = 0 (and MaxSearchReportCount = 0). By default the last 5 indexing reports are retained, and each one holds the full list of document paths from its batch. With 10k-document batches that’s 50k retained path strings for no benefit.

  7. Don’t switch to CompactIndex for memory reasons — see the table above.

  8. Call Optimize() sparingly and never concurrently with adds — it takes the same deep-copy path as indexing.

If you apply (1), (2), (4) and (6), I would expect the growth curve to flatten substantially even before we ship anything.

What we scheduled for the next releases

Scoped internally, in priority order:

  • Rework and optimize the index format and creation.. This will decrease disk allocation and potentially RAM usage potentially as well
  • Grow the term-collector buffers on demand. Currently preallocated up front per Add() per thread, regardless of batch size. This is the 1.48 GB fixed term — 97.6% of all allocation in the per-document run. Takes small batches from GB to MB and removes most of the LOH churn with it.
  • Replace the commit-side deep copy with an ownership transfer. The working state is discarded on the next line, so cloning it is pure waste: one term-dictionary clone plus one full metadata copy per operation. Scale-dependent — invisible on a small index, gigabytes per commit at 1M documents. This is the frame in your stack trace.
  • Fix metadata sizing and duplication. Document paths held as two separate string instances; two internal dictionaries pre-sized 3–5× too large. ~200–300 MB per million documents.
  • Document the cost model and the scale figure. The per-Add() formula and the 969,288-document result belong in the docs, not in a forum thread.

Later, and no date on it: memory-map the metadata and term dictionary behind a bounded cache. That is what would turn question 1 into a real setting rather than an answer of “no”.


May I ask you to share a few details from your side

A few of these change the diagnosis materially:

  1. What is your batch size — how many documents per Add(...) call? If it’s 1–100, that alone accounts for most of what you’re seeing.
  2. Which overload are you calling — Add(ExtractedData[], ...), Add(Document[], ...), or the path-based ones? And what is IndexingOptions.Threads set to?
  3. What are your IndexSettings? Specifically, IndexType (Normal / Compact / Metadata), whether TextStorageSettings is set, UseStopWords, and whether OCR or image indexing is enabled.
  4. Runtime details: .NET Framework or .NET 8+? Server GC or workstation GC? 64-bit process (I assume yes, given the numbers)?
  5. Index shape: Are the 1M “files” plain documents, or do they include archives / PST / mail that expand into many container items? Container items count as documents for every structure listed above. And roughly how many fields per document — the element tables scale with documents × fields.
  6. File sizes: please post the sizes of index.info, index1.info and index0.info next to the 25 GB total. That converts my estimate in question 4 into your actual number.
  7. How often do you call Optimize(), and how many segments accumulate between optimizations?
  8. Does memory climb during Add, during Optimize, or during search? Your trace points at Add, but confirming rules out a second issue.
  9. Do you keep one Index instance open for the whole run, or open and dispose one per batch?

If you can share your main code snippet of the main indexing process implementation and optionally with a memory profile snapshot (dotMemory / PerfView) taken mid-run, that would help us for investigation.

Hello @yuriy.mazurchuk, thank you for your response.

I read your response and will probably re-read it. But I can already answer most of your follow-up questions.

1. Batch size — how many documents per Add(…) call?

Not a fixed count — chunks are byte-driven, not document-count-driven in our case. Extracted data accumulates in a queue and gets flushed into a single Index.Add() call once the running chunk reaches 75 MB. That’s usually 500 - 2000 files.

For the future, we also plan to observe file changes on disk and want to apply them on the index when they happen… That would mean “calling many Adds for just 1 file”. You’d probably recommend to instead collect many of them first?

2. Which overload, and IndexingOptions.Threads?

We use Index.Add(ExtractedData[] documents, IndexingOptions options) — the pre-extracted-data array overload, not Add(Document[], ...) and not the path-based Add(string[], ...). Extraction itself (Extractor.Extract(Document, ExtractionOptions)) happens one document at a time in a separate out-of-process worker; only the later Index.Add on the array of ExtractedData is batched.

IndexingOptions.Threads is never explicitly set anywhere in our codebase — we rely on the library default at every call site.

3. IndexSettings

Only one property is ever set, everywhere an index is opened: new IndexSettings { UseStopWords = false }. Everything else is default:

  • IndexType: not set → default NormalIndex (we are not using CompactIndex)
  • TextStorageSettings: not set (no in-index text storage)
  • OCR / image indexing: not used at all
  • MaxIndexingReportCount / MaxSearchReportCount: not set → library defaults

4. Runtime

All indexing-relevant projects target .NET 10 (net10.0-windows), Microsoft.NET.Sdk.Web, published self-contained/single-file. We do not explicitly set ServerGarbageCollection, ConcurrentGarbageCollection, or any GC heap-limit/gcAllowVeryLargeObjects setting anywhere in .csproj/app.config/runtimeconfig.template.json — so whatever the SDK’s implicit default is for that project shape is what’s in effect (typically Server GC for an ASP.NET Core self-contained exe, but we haven’t verified the actual published runtimeconfig.json). Process is 64-bit.

5. Index shape — files, containers, fields

We tried the corpera dataset, too. Mostly office files.
We deliberately exclude PST and ZIP/archives today (unrelated to this RAM issue).

6. Index storage / file sizes

ls output for attempt 1:

 165443569 index.info
1595168278 index0.body
  97197852 index0.head
        20 index0.info
1540830192 index1.body
 159123860 index1.head
2002366882 index1.info
1599477714 index10.body
 654719980 index10.head
1590211058 index11.body
 705820984 index11.head
1589495222 index12.body
 801350388 index12.head
1542149739 index13.body
 845734836 index13.head
1561119992 index14.body
 891020764 index14.head
1565702518 index15.body
 922562644 index15.head
 363049228 index16.body
 929697532 index16.head
         0 index17.body
         0 index17.head
  67162450 index18.body
 930307660 index18.head
         0 index19.body
         0 index19.head
1580775681 index2.body
 219554144 index2.head
 102033776 index20.body
 940052016 index20.head
  99483780 index21.body
 949658692 index21.head
  44303365 index22.body
 953742328 index22.head
1572039963 index3.body
 277438468 index3.head
1526240642 index4.body
 332893268 index4.head
1593192949 index5.body
 387100800 index5.head
1595790184 index6.body
 453835180 index6.head
1582514363 index7.body
 503980424 index7.head
1567275157 index8.body
 545109084 index8.head
1581014645 index9.body
 596315884 index9.head
         0 indexAliases.term
     11733 indexAlphabet.term
      2476 indexCharacterReplacements.term
         0 indexDocumentPasswords.dat
      9375 indexHomophones.term
   1471115 indexSpellingCorrector.dat
      4146 indexStopWords.term
     90144 indexSynonyms.term
    505033 tokenizationDictionary.term
  

ls output for attempt 2:

  57280077 index.info
1589006905 index0.body
   2411068 index0.head
        20 index0.info
1604667896 index1.body
   2715488 index1.head
1523023332 index1.info
 624835075 index10.body
 809917288 index10.head
         0 index11.body
         0 index11.head
 100202498 index12.body
 813128416 index12.head
         0 index13.body
         0 index13.head
  98016945 index14.body
 816056364 index14.head
         0 index15.body
         0 index15.head
  93318568 index16.body
 819424856 index16.head
1585988785 index2.body
 117424268 index2.head
1589220017 index3.body
 261219216 index3.head
1519477769 index4.body
 391290668 index4.head
1590605778 index5.body
 478980660 index5.head
1560397166 index6.body
 575476304 index6.head
1584897467 index7.body
 658322952 index7.head
1541610266 index8.body
 734981008 index8.head
1499028813 index9.body
 788711896 index9.head
         0 indexAliases.term
     11733 indexAlphabet.term
      2476 indexCharacterReplacements.term
         0 indexDocumentPasswords.dat
      9375 indexHomophones.term
   1471115 indexSpellingCorrector.dat
      4146 indexStopWords.term
     90144 indexSynonyms.term
    505033 tokenizationDictionary.term

7. Optimize() frequency

We currently call Optimize after adding around 300 MB.

8. When does the memory climb

TODO (I don’t have an answer for that yet).

9. One index instance

Yes. We currently keep one index instance for a whole run (call Add() and Optimize() on it many times).


Follow up points:

Progress

Add() or Optimize() are operations take can take minutes (and by adding even larger chunks at once or by fewer calling Optimize(), it will take even longer once we call them). For us on the consuming side, it’d be valuable to have some progress for those operations.

Index size

You wrote:

Source corpus 986,262 files, 466 GB
Documents indexed 969,288
Extracted text 91.2 GB
Resulting index 440 GB across 740 segments
Machine 32 GB RAM
Outcome completed, no errors

Although I’m happy that you say that it’s possible to deal with a 440 GB index with only 32 GB of RAM, I’m a bit surprised about the size on disk.
When I think about an index and had to tell what a perfect index size would be, I’d say: 1%.
I asked my colleagues what they expected it to be at most, and they responded around 15-33%.
But in your case, the resulting index with its 440 GB has 94% of the size of the original data. Do you know why it is so large / can’t or shouldn’t it be smaller? Was Optimize() called in the end?

Best regards
Jam

Hi Jam @jamsharp!

Thank you for the detailed response and the provided indexing storage information - that is very helpful.

Your index has ~238 million unique terms. index1.info (the term dictionary) is 2.0 GB on disk, gets loaded into RAM in full, and expands 2–4× when decoded. Every Add() and every Optimize() clones it twice — once on entry, once on commit. That is your 20 GB, and it is why the stack trace lands in MultiArrayTree.Clone().

Your batching is fine. The problems are vocabulary size and, probably, Server GC.


Following your points

1. Batch size — 75 MB / 500–2000 files. That’s fine, nothing to change. You’re already spreading the ~1.48 GB per-Add() fixed cost over 500–2000 docs (~1–3 MB/doc). This is the correct approach for the current version.

File watcher: yes, buffer first. One file per Add() costs ~1.5 GB per file. Flush on whichever hits first — 75 MB, 500 docs, or a 30–60 s timer so a slow trickle still lands. Single-file Add() is fine as a rare “user uploaded this, make it searchable now” path, not as steady state. Also note updates/deletes don’t reclaim old postings until Optimize() runs, so a change-watcher grows the index even at constant document count.

2. Overload / Threads. Add(ExtractedData[], …) with out-of-process extraction is the right shape. Threads defaults to 1 — leave it. Raising it multiplies the 1.48 GB collector buffers per thread.

3. UseStopWords = false. Costing you. Stop words are 30–40% of tokens in English text, and you’re giving each one full positional postings. .body is 63% of your index. Turn it back on unless you genuinely need phrase search through stop words. That change could also help.

4. Runtime — .NET 10, Microsoft.NET.Sdk.Web, self-contained. Check this first. The Web SDK turns on Server GC by default. Look in your published runtimeconfig.json:

"configProperties": { "System.GC.Server": true }

Server GC = one heap per core, each with its own LOH, and it holds onto memory far longer than workstation GC. Combined with 1.48 GB LOH-sized buffers per Add() plus a multi-GB tree clone, on a 16–32 core box that’s a completely different profile. For the indexing process:

<ServerGarbageCollection>false</ServerGarbageCollection>
<ConcurrentGarbageCollection>false</ConcurrentGarbageCollection>

If the same process serves HTTP and you want to keep Server GC, cap it instead: "System.GC.HeapCount": 4, "System.GC.Conserve": 5. And between batches:

GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce;
GC.Collect();

Config-only, no reindex. Cheapest thing to test.

6. File sizes. Attempt 1, 38.3 GB total:

Component Size Share What it is
index*.body 24.08 GB 63% Postings — the real inverted index
index*.head 12.20 GB 32% Term offsets — 4 bytes per term, per segment
index1.info 1.86 GB 5% Term dictionary
index.info 0.15 GB 0.4% Document metadata

.head is a flat int[] indexed by term id, so size ÷ 4 = term count. Your largest is 953,742,328 bytes → 238,435,582 terms. A large English corpus is usually 10–50M. You’re 5–20× that, so most of it isn’t vocabulary — it’s hex, base64, GUIDs, timestamps, OCR noise. Terms can be 80 chars, so one base64 blob yields many distinct “words”.

The memory chain, from that 2.0 GB dictionary:

decode expansion resident + entry clone peak at commit
3.7 GB 7.5 GB 11.2 GB
5.6 GB 11.2 GB 16.8 GB
7.5 GB 14.9 GB 22.4 GB

Add 1.48 GB of collector buffers, two metadata copies, and Server GC retention → 32 GB gone. Your stack trace is the third column.

7. Optimize() every 300 MB. Too often. It takes the same deep-copy path as indexing, and each merge allocates offset arrays sized to your whole vocabulary — a 953 MB allocation per merge step at 238M terms. Move it to end-of-run or a quiet window. Don’t drop it entirely though, see below.

8. When memory climbs. Cheap instrumentation:

Console.WriteLine($"alloc={GC.GetTotalAllocatedBytes(false)>>20}MB " +
                  $"private={Process.GetCurrentProcess().PrivateMemorySize64>>20}MB " +
                  $"gen2={GC.CollectionCount(2)}");

Log around each Add() and Optimize(). Sharp step at Optimize() → clone path. Steady climb across Add()s that never comes back → LOH fragmentation, so point 4 is your fix.

9. One index instance. Keep it — right call for throughput. Just means the 2.0 GB dictionary is resident for the whole run, and every operation peaks on top of that floor.

Progress reporting

Already there, both operations:

index.Events.OperationProgressChanged += (s, e) =>
    Console.WriteLine($"{e.ProcessedDocuments}/{e.TotalDocuments} ({e.ProgressPercentage:F1}%) " +
                      $"skipped={e.SkippedDocuments} last={e.LastDocumentKey}");

index.Events.OptimizationProgressChanged += (s, e) =>
    Console.WriteLine($"segment {e.ProcessedSegments}/{e.TotalSegments} ({e.ProgressPercentage:F1}%)");

OperationProgressChanged also gives you LastDocumentPath and LastDocumentStatus — handy for finding the document that’s eating the time.

Index size — why so large

Fair challenge. I will schedule a verification run on 1M documents to investigate potential improvements on this and size increasing factors.

A third of your index isn’t index data. Every segment’s .head carries a 4-byte slot for every term in the whole index, present in that segment or not. index22.head is 910 MB describing a segment whose .body is 42 MB — almost entirely -1 padding. It scales as 4 × vocabulary × segments, which is why 238M terms × 23 segments hurts.

Merging collapses it:

now after full Optimize()
.head 12.20 GB 0.89 GB
total 38.30 GB ~27 GB

~11 GB, ~30% of your index, recoverable with one final Optimize().

1% isn’t achievable for a positional full-text index over office documents. 15–33% is realistic once the .head overhead and the junk vocabulary are gone.


What you can do

  1. Check System.GC.Server, try workstation GC on the indexing process. Config only, no reindex, biggest chance of an immediate fix.
  2. One Optimize() at end of run instead of every 300 MB. No reindex. Recovers ~30% of disk.
  3. Cut the vocabulary. This is the real fix for the OOM:
using GroupDocs.Search.Dictionaries;

// digits as separators kills every date, version, ID and hash fragment
index.Dictionaries.Alphabet.SetRange("0123456789".ToCharArray(), CharacterType.Separator);

Consider +, /, = too (base64), plus a DocumentFilter for file types that are mostly machine noise. Reindex a subset and watch index1.info — if 2.0 GB drops to a few hundred MB, the OOM goes with it. Changes tokenisation, so it needs a full reindex and alters what queries match. Test on a subset.
4. UseStopWords = true unless you need phrase search through them.
5. Buffer the file-watcher instead of one Add() per change.

1 and 2 are free. 3 is the one that actually fixes it.

What we schedule for the next product releases

  1. Grow the term-collector buffers on demand instead of preallocating ~1.43 GB per Add() per thread. Biggest single win, no format change.
  2. Ownership transfer instead of the commit-side clone. The working state is discarded on the next line, so cloning it is pure waste. Scale-dependent — at your dictionary size it’s the difference between column 2 and column 3 in that table. This is your crash.
  3. Rework the .head layout. A sparse or delta-encoded offset table removes most of that 32% overhead. Your data is what put this on the list.
  4. Metadata sizing/duplication fixes — paths held as two string instances, a couple of dictionaries oversized 3–5×. ~200–300 MB per million docs.
  5. NuGet split-framework packaging with a native .NET 10 target, September release. Relevant to you specifically: the package currently ships net462 and netstandard2.1, so on .NET 10 you’re running the netstandard build. A real net10.0 target lets us use APIs that don’t exist in netstandard2.1 — GC.AllocateUninitializedArray in particular, which skips zeroing on exactly the multi-hundred-MB buffers you’re paying for now.
  6. Document the cost model and the scale figures rather than leaving them in forum threads.

Longer terms

  1. Memory-mapping the term dictionary and metadata behind a bounded cache. That’s what would make “less RAM, slower search” a real option instead of an answer of “no”.

  2. The indexing optimization with multiple disk sizes and search performance is under consideration as well but this change will impact the format/structure.

Please share details

Can you please share how hard for you to run re-indxing on production environment?

For example, we prepared index structure/format optimization changes that will require the new structure/re-indexing of all documents. One of the potential alternatives is the index conversion between versions.
May I ask you to share details behind the scenes - how many documents are indexed in production, how hard for you to provide a re-indexing process or you would prefer a conversion process. What if re-indexing takes more than a “weekend period” - is it possible to prepare a mirror “production” environment and then switch to this environment instead of keeping customers waiting until the technical services are completed on prod? So any of these details will help us to understand your requirements and adjust product behavior and features.
You may send me a private message over the forum to keep corporate information non-public.

Thank you!

Hello,

I created a new topic for that index size, so we have that separated: Indexes with over 33%, sometimes even ~100% of the size of the original data

We implemented multiple of your suggestions and additional logging regarding memory usage.

We currently try out indexing the Corpora dataset again, but without additional alphabet characters + with the default stop words enabled again and see whether it works without exception.

But we are of course excitedly waiting for the mentioned fixes on your side (especially the MultiArrayTree.Clone() one).

Thanks and best regards,
Jam