Skip to content

Commit 38bd135

Browse files
committed
fix: High RAM Usage
1 parent 6f2f63a commit 38bd135

16 files changed

Lines changed: 1261 additions & 409 deletions

README.md

Lines changed: 90 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,9 @@ v1 was a semantic search tool. **v2 is a full launcher, and it is dramatically f
5656
| **Preview** | Inline snippet | **Full split preview panel with syntax highlighting and metadata** |
5757
| **Extras** | None | **Calculator, unit and currency conversion, system actions, web search shortcuts** |
5858
| **Fallback** | None | **Windows Search Indexer fallback when running unelevated** |
59-
| **Engine control** | Fixed | **OCR toggle, int8 quantization for ~2x faster indexing** |
59+
| **Engine control** | Fixed | **OCR toggle, hardware aware int8 quantization** |
60+
| **Indexing throughput** | Baseline | **~1.6x faster end to end, on ~30% less peak memory** |
61+
| **Native index footprint** | 3 heap allocations per file | **Zero: names live in one arena per volume, ~65 MB less per million files** |
6062

6163
Everything below is local. No account, no API key, no network call, no telemetry. Ever.
6264

@@ -79,7 +81,7 @@ A Rust engine reads the **NTFS Master File Table** directly via `FSCTL_ENUM_USN_
7981

8082
### Real understanding
8183

82-
A local Python sidecar chunks and embeds the text inside your documents and code with `sentence-transformers`, so you can search by **meaning**, not just by file name.
84+
A local Python sidecar chunks and embeds the text inside your documents and code with a multilingual MiniLM model running on ONNX Runtime, so you can search by **meaning**, not just by file name — in English and Turkish alike.
8385

8486
</td>
8587
<td width="33%" valign="top">
@@ -118,15 +120,15 @@ If LocalMind is running without administrator rights, it transparently falls bac
118120

119121
<img src="docs/screenshots/semantic-search.svg" width="880" alt="Semantic search finding content inside documents and code" />
120122

121-
Ask for what you remember, not for what the file is called. *"that Docker PDF I downloaded"*, *"the React login component"*, *"jwt auth implementation"*. LocalMind encodes your query with `all-MiniLM-L6-v2`, compares it against every text chunk stored in LanceDB, and returns the closest matches with the exact line range, so you land on the relevant paragraph rather than on the top of a 90 page document.
123+
Ask for what you remember, not for what the file is called. *"that Docker PDF I downloaded"*, *"the React login component"*, *"jwt auth implementation"*. LocalMind encodes your query with a multilingual MiniLM-L12 model, compares it against every text chunk stored in LanceDB, and returns the closest matches with the exact line range, so you land on the relevant paragraph rather than on the top of a 90 page document.
122124

123125
**Highlights**
124126

125-
- Local `all-MiniLM-L6-v2` embeddings, roughly 80 MB, downloaded once and cached
127+
- Local multilingual MiniLM-L12 embeddings, downloaded once and cached in `~/.localmind/models`
126128
- LanceDB vector store, file based, no server process to run
127129
- Overlapping ~500 character chunks with full line tracking
128130
- Relevance score and line range shown on every hit
129-
- Optional int8 dynamic quantization for roughly 2x faster indexing
131+
- int8 quantization for a ~2.2x smaller memory footprint, chosen automatically on CPU-only machines
130132
- Optional OCR for scanned images and screenshots
131133

132134
<br />
@@ -242,7 +244,7 @@ Dark and light themes, English and Turkish out of the box (the i18n layer is rea
242244
|---|---|
243245
| **Text & Config** | `.txt` `.md` `.json` `.csv` `.xml` `.yaml` `.yml` `.toml` `.log` `.env` `.sql` |
244246
| **Source Code** | `.js` `.ts` `.tsx` `.jsx` `.py` `.rs` `.go` `.java` `.c` `.cpp` `.h` `.rb` `.sh` `.bat` `.html` `.css` `.r` |
245-
| **Documents** | `.pdf` `.docx` `.xlsx` `.pptx` `.ipynb` |
247+
| **Documents & Books** | `.pdf` `.docx` `.xlsx` `.pptx` `.ipynb` `.epub` `.rtf` |
246248
| **Images** *(OCR, optional)* | `.png` `.jpg` `.jpeg` `.bmp` `.tiff` |
247249

248250
<br />
@@ -268,8 +270,8 @@ Dark and light themes, English and Turkish out of the box (the i18n layer is rea
268270
│ Python AI Engine │
269271
│ (FastAPI sidecar) │
270272
│ │
271-
│ • sentence-transformers
272-
│ (all-MiniLM-L6-v2)
273+
│ • ONNX Runtime embeddings
274+
│ (multilingual MiniLM-L12)
273275
│ • LanceDB vector store │
274276
│ • watchdog file watcher │
275277
│ • Text extractors │
@@ -289,18 +291,67 @@ Dark and light themes, English and Turkish out of the box (the i18n layer is rea
289291
**Semantic search flow (Python sidecar)**
290292

291293
1. In parallel, the frontend sends `POST /search` to the local sidecar
292-
2. The sidecar encodes the query with `all-MiniLM-L6-v2`
294+
2. The sidecar encodes the query with a multilingual MiniLM-L12 model through ONNX Runtime (DirectML on a DirectX 12 GPU when one is available, otherwise CPU)
293295
3. LanceDB returns the top-*k* nearest chunks
294296
4. Results (file path, snippet, relevance score, line range) are merged into the same UI list
295297

296298
**Indexing flow**
297299

298-
1. Recursively scans the configured folders
299-
2. Extracts text with `pdfplumber`, `python-docx`, `openpyxl`, `python-pptx`, plain text readers and optional OCR
300-
3. Splits text into overlapping ~500 character chunks with line tracking
301-
4. Generates embeddings via `sentence-transformers`, optionally int8 quantized
302-
5. Stores vectors in LanceDB, a local file based database that needs no server
303-
6. `watchdog` monitors the file system and updates the index incrementally
300+
1. Streams the configured folders with `os.scandir`, reusing the stat data the directory listing already returned
301+
2. Skips anything whose size and modification time still match the stored hash, so a re-index only touches what changed
302+
3. Extracts text on a worker pool with PyMuPDF, `python-docx`, `openpyxl`, `python-pptx`, plain text readers and optional OCR
303+
4. Splits text into overlapping ~500 character chunks with line tracking
304+
5. Groups chunks by length and embeds them in batches, optionally int8 quantized
305+
6. Buffers the resulting rows and writes them to LanceDB, a local file based database that needs no server
306+
7. `watchdog` monitors the file system and updates the index incrementally
307+
308+
Extraction and embedding run concurrently: the worker pool always has files in flight, so the model is never waiting on a slow PDF and the disk is never waiting on the model.
309+
310+
<br />
311+
312+
## Performance
313+
314+
Indexing speed and memory use are the two things you actually feel, so both are treated as features rather than side effects.
315+
316+
**Where indexing time goes, and what was done about it**
317+
318+
| Change | Effect |
319+
|---|---|
320+
| **Length-grouped, dynamically padded batches** | Chunks are sorted by length and each batch is padded to its own longest sequence instead of a fixed 192 tokens. A short chunk no longer costs a full-length forward pass. **2.7x** faster embedding on a mixed corpus. |
321+
| **Buffered database writes** | Rows are accumulated and written to LanceDB in batches rather than one write per embedding batch. Every write creates a dataset fragment, and thousands of tiny fragments were slow to produce and slow to compact afterwards. **~30x** faster on the write path (20k rows: 6.9s → 0.2s). |
322+
| **Continuous extraction pipeline** | Extraction workers are re-fed as each result is consumed, instead of draining a fixed window before starting the next one. No worker sits idle behind the slowest file in its window. |
323+
| **Rate-limited index statistics** | The distinct-file rollup behind `/index/stats` needs a full column scan. The UI polls it every second, so during a run the engine used to rescan the whole table once per second while it was already busy. |
324+
325+
End to end on a mixed 900-file corpus (13,836 chunks): **70.8s → 45.3s, a 1.56x speedup, with peak memory falling from 38.3 MB to 27.8 MB.**
326+
327+
**Where memory goes, and what was done about it**
328+
329+
| Change | Effect |
330+
|---|---|
331+
| **Arena-backed native index** | An MFT record used to own three separate `String`s (`name`, `name_lower`, `ext`). Names now live in one arena per volume and a record is a fixed 32-byte slice reference; the extension is derived on demand. **~65 MB less per million files**, and zero heap allocations per file instead of three. |
332+
| **Bounded search result selection** | A broad query used to collect *every* match before sorting it. Only the best candidates are kept now, which bounds both the allocation and the sort. |
333+
| **Streaming folder scan** | The scan yields entries instead of materializing one record per candidate file. On a re-index, unchanged files cost a single path string rather than a full entry — **~33 MB less at 150k files**. |
334+
| **Released hash cache** | The path-to-hash map that answers "has this file changed?" is dropped when a run finishes and rebuilt lazily on the next one, instead of sitting resident for the life of the sidecar. |
335+
| **Memory-balanced batch size** | The inference batch is the main memory dial, since activations scale with batch size. 32 measured within 10% of 64 while holding 24 MB less, so it is the default. |
336+
337+
**int8 quantization is a real tradeoff, not a free win**
338+
339+
The embedding model is the largest single thing LocalMind holds, and its precision decides both how much memory that is and how fast indexing runs. Measured on a mixed 600-chunk batch:
340+
341+
| Model precision | Execution provider | Throughput | Peak process memory |
342+
|---|---|---|---|
343+
| fp32 | DirectML (GPU) | **448 chunks/s** | 1216 MB |
344+
| fp32 | CPU | 112 chunks/s | 1143 MB |
345+
| int8 | CPU | 206 chunks/s | **556 MB** |
346+
347+
Dynamic quantization emits operations DirectML cannot execute, so an int8 graph falls back to the CPU no matter what hardware you have. That leads to a rule with no exceptions:
348+
349+
- **On a DirectX 12 GPU**, fp32 is 2.2x faster and int8 is 2.2x lighter. Neither wins outright, so LocalMind leaves the choice to you and defaults to fp32.
350+
- **On a CPU-only machine**, int8 is both faster *and* lighter than fp32. There is nothing to weigh, so LocalMind uses it.
351+
352+
The quantization toggle in settings overrides this whenever you want. Switching it rewrites the index, because the two precisions produce different vectors. Choosing int8 also skips the GPU provider entirely, which saves a further ~155 MB that would otherwise be held for a GPU partition the quantized graph never uses.
353+
354+
Figures come from the benchmarks in this repository's history and from `cargo test --lib footprint -- --nocapture`, which prints the per-record footprint table. They will vary with your CPU, GPU, drive and corpus.
304355

305356
<br />
306357

@@ -312,9 +363,9 @@ Dark and light themes, English and Turkish out of the box (the i18n layer is rea
312363
| Frontend | React 19 · TypeScript · Vite · Tailwind CSS v4 |
313364
| Native search engine | Rust · `rayon` · NTFS MFT / USN Journal · Windows Search fallback |
314365
| AI engine | FastAPI · Uvicorn · Python |
315-
| Embeddings | [sentence-transformers](https://www.sbert.net/) · `all-MiniLM-L6-v2` |
316-
| Vector database | [LanceDB](https://lancedb.com/) |
317-
| Text extraction | `pdfplumber` · `python-docx` · `openpyxl` · `python-pptx` |
366+
| Embeddings | [ONNX Runtime](https://onnxruntime.ai/) (DirectML) · `paraphrase-multilingual-MiniLM-L12-v2` · `tokenizers` |
367+
| Vector database | [LanceDB](https://lancedb.com/) · `pyarrow` |
368+
| Text extraction | `PyMuPDF` · `python-docx` · `openpyxl` · `python-pptx` · `pdfplumber` (fallback) |
318369
| File watching | `watchdog` |
319370
| Syntax highlighting | `highlight.js` |
320371
| i18n | `i18next` · `react-i18next` |
@@ -352,7 +403,7 @@ cd ..
352403
npm run tauri dev
353404
```
354405

355-
> **First run:** the `all-MiniLM-L6-v2` model (~80 MB) downloads automatically and is cached for every later launch. For full speed native file search LocalMind can request administrator privileges to read the NTFS Master File Table directly; without elevation it falls back to the Windows Search Indexer automatically, so it works either way.
406+
> **First run:** the multilingual MiniLM-L12 model downloads automatically and is cached in `~/.localmind/models` for every later launch. On a CPU-only machine it is converted to int8 once, which takes about a minute and is then reused. For full speed native file search LocalMind can request administrator privileges to read the NTFS Master File Table directly; without elevation it falls back to the Windows Search Indexer automatically, so it works either way.
356407
357408
<br />
358409

@@ -385,9 +436,27 @@ All settings are reachable from the in-app settings panel (gear icon) or the sys
385436
| Indexed Folders | Documents, Downloads, Desktop | Folders scanned recursively for content indexing |
386437
| Exclude Patterns | `node_modules`, `*.min.js`, `*.log`, `.git` | Glob patterns to ignore |
387438
| OCR | Off | Extract text from scanned images, adds roughly 500 MB of models |
388-
| Embedding Quantization | Off | int8 dynamic quantization, roughly 2x faster indexing |
439+
| Embedding Quantization | Auto | int8 model: ~2.2x less memory, and on a CPU-only machine ~1.8x faster too. Auto picks int8 when there is no GPU, fp32 when there is. See [Performance](#performance). Changing it moves every vector to a different space, so the index is rebuilt |
440+
441+
Engine level settings are also controllable through environment variables (`LOCALMIND_OCR`, `LOCALMIND_QUANTIZE`), which always take precedence over the stored configuration. `LOCALMIND_QUANTIZE=auto` restores the hardware based choice.
442+
443+
### Performance tuning
389444

390-
Engine level settings are also controllable through environment variables (`LOCALMIND_OCR`, `LOCALMIND_QUANTIZE`), which always take precedence over the stored configuration.
445+
The defaults are tuned for a balance of speed and memory on an ordinary laptop and should not need touching. If you want to trade one for the other, these environment variables are read by the sidecar at startup:
446+
447+
| Variable | Default | What it does |
448+
|---|---|---|
449+
| `LOCALMIND_EMBED_BATCH` | `32` | Chunks per model forward pass. The main memory dial: activations scale with it. Raise it for throughput on a machine with RAM to spare, lower it if the sidecar is squeezed |
450+
| `LOCALMIND_EMBED_POOL` | `128` | Chunks handed to the embedder at once. Larger pools give length grouping more to work with and waste less padding |
451+
| `LOCALMIND_DB_FLUSH_ROWS` | `1000` | Rows buffered before a LanceDB write. Larger means fewer, bigger fragments on disk |
452+
| `LOCALMIND_EXTRACT_WORKERS` | half your cores, max 6 | Parallel text extraction threads. Extraction and the embedding model compete for the same cores, so giving extraction all of them makes indexing slower, not faster |
453+
| `LOCALMIND_INDEX_WINDOW` | `24` | Files held in flight. This is what bounds peak memory during a run |
454+
| `LOCALMIND_MAX_SEQ` | `192` | Token ceiling per chunk |
455+
| `LOCALMIND_PAD_MULTIPLE` | `32` | Batches pad to their longest sequence rounded up to this. Smaller wastes less padding but shows the model more distinct input shapes to plan for |
456+
| `LOCALMIND_ONNX_THREADS` | half your cores, max 8 | ONNX Runtime intra-op threads |
457+
| `LOCALMIND_STATS_INTERVAL` | `15` | Seconds between full recounts behind `/index/stats` |
458+
| `LOCALMIND_MAX_PDF_PAGES` | `200` | Pages read per PDF |
459+
| `LOCALMIND_MAX_CHUNKS_PER_FILE` | `200` | Chunks kept per file, so one huge document cannot monopolize a run |
391460

392461
<br />
393462

ai_engine/build.py

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,12 @@ def build():
4141
"fastapi",
4242
"lancedb",
4343
"pyarrow",
44-
"sentence_transformers",
4544
"tokenizers",
4645
"onnxruntime",
46+
# onnx (and its protobuf dependency) is what onnxruntime.quantization
47+
# needs for the one-off int8 conversion. Without it the packaged build
48+
# silently falls back to the 449MB fp32 weights.
49+
"onnx",
4750
"rapidfuzz",
4851
"watchdog",
4952
"openpyxl",
@@ -64,15 +67,33 @@ def build():
6467
"numpy",
6568
"huggingface-hub",
6669
"safetensors",
67-
"sentence-transformers",
6870
"tokenizers",
6971
"onnxruntime",
72+
"onnx",
7073
"fastapi",
7174
"uvicorn",
7275
"lancedb",
7376
"pyarrow",
7477
]
7578

79+
exclude_modules = [
80+
"torch",
81+
"torchvision",
82+
"torchaudio",
83+
"scipy",
84+
"matplotlib",
85+
"pandas",
86+
"IPython",
87+
"jupyter",
88+
"transformers",
89+
"sentence_transformers",
90+
"tensorboard",
91+
"caffe2",
92+
"tkinter",
93+
"test",
94+
"unittest",
95+
]
96+
7697
hidden_imports = [
7798
"uvicorn.logging",
7899
"uvicorn.loops",
@@ -85,6 +106,7 @@ def build():
85106
"uvicorn.lifespan",
86107
"uvicorn.lifespan.on",
87108
"uvicorn.lifespan.off",
109+
"onnxruntime.quantization",
88110
"winsearch",
89111
"app_launcher",
90112
"chunker",
@@ -117,6 +139,9 @@ def build():
117139
for meta in copy_metadata:
118140
cmd.extend(["--copy-metadata", meta])
119141

142+
for exc in exclude_modules:
143+
cmd.extend(["--exclude-module", exc])
144+
120145
for hi in hidden_imports:
121146
cmd.extend(["--hidden-import", hi])
122147

0 commit comments

Comments
 (0)