graph-indexer 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,16 +1,24 @@
1
- # graph-indexer
1
+ <div align="center">
2
+ <!-- Reemplaza la URL de abajo con la ruta a tu nuevo logo profesional en formato SVG o PNG transparente -->
3
+ <img src="https://raw.githubusercontent.com/MaquinaTech/graph-indexer/main/assets/logo.jpg" alt="Graph Indexer Logo" width="250" />
2
4
 
3
- <p align="center">
4
- <strong>Zero-Database · Air-Gapped · AST-Precise · Hybrid Search</strong><br>
5
- <em>An MCP server that lets AI coding agents search your codebase by concept instead of reading files — reducing context usage by 65–88% per query.</em>
6
- </p>
5
+ <h1>Graph Indexer</h1>
7
6
 
8
- <p align="center">
9
- <a href="https://www.npmjs.com/package/graph-indexer"><img src="https://img.shields.io/npm/v/graph-indexer?color=blue&style=flat-square" alt="npm version"></a>
10
- <a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square" alt="License: MIT"></a>
11
- <a href="https://nodejs.org"><img src="https://img.shields.io/badge/Node.js-%3E%3D18-green?style=flat-square" alt="Node.js 18+"></a>
12
- <a href="#results"><img src="https://img.shields.io/badge/Recall@5-100%25-brightgreen?style=flat-square" alt="Recall@5 100%"></a>
13
- </p>
7
+ <p>
8
+ <strong>Zero-Database · Air-Gapped · AST-Precise · Hybrid Search · Monorepo-Scale</strong>
9
+ </p>
10
+ <p>
11
+ <em>An MCP server that lets AI coding agents search your codebase by concept instead of reading files, cutting context usage by 65–88% per query.</em>
12
+ </p>
13
+
14
+ <!-- Badges -->
15
+ <a href="https://www.npmjs.com/package/graph-indexer"><img src="https://img.shields.io/npm/v/graph-indexer?color=007acc&style=for-the-badge" alt="npm version"></a>
16
+ <a href="https://www.npmjs.com/package/graph-indexer"><img src="https://img.shields.io/npm/dt/graph-indexer?color=4caf50&style=for-the-badge" alt="NPM Downloads"></a>
17
+ <a href="https://nodejs.org"><img src="https://img.shields.io/badge/Node.js-%3E%3D18-brightgreen?style=for-the-badge&logo=nodedotjs" alt="Node.js 18+"></a>
18
+ <a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-yellow.svg?style=for-the-badge" alt="License: MIT"></a>
19
+ </div>
20
+
21
+ <br />
14
22
 
15
23
  ---
16
24
 
@@ -19,6 +27,8 @@
19
27
  - [What it does](#what-it-does)
20
28
  - [Results](#results)
21
29
  - [Getting started](#getting-started)
30
+ - [Storage backends](#storage-backends)
31
+ - [Semantic enrichment](#semantic-enrichment)
22
32
  - [MCP tools](#mcp-tools)
23
33
  - [Configuration](#configuration)
24
34
  - [Supported languages](#supported-languages)
@@ -33,9 +43,12 @@
33
43
 
34
44
  ## What it does
35
45
 
36
- graph-indexer pre-indexes your codebase into an AST-precise search index and exposes it as an MCP server. Instead of reading full files, AI agents call `search_code("payment validation")` and get back the exact functions that match — with type signatures, docstrings, dependency graph, and call sites — in a fraction of the tokens.
46
+ graph-indexer pre-indexes your codebase into an AST-precise search index and exposes it as an MCP server. Instead of reading full files, AI agents call `search_code("payment validation")` and get back the exact functions that match — with type signatures, docstrings, decorators, inheritance edges, dependency graph, and call sites — in a fraction of the tokens.
47
+
48
+ It runs entirely on your machine. No external database, no cloud APIs, no telemetry. Two design choices make it suitable for any repository size:
37
49
 
38
- It runs entirely on your machine. No external database, no cloud APIs, no telemetry.
50
+ - A **default in-memory engine** that is instant and dependency-free, and an optional **disk-backed SQLite backend** that holds the index on disk so retrieval RAM stays flat on enterprise monorepos that would otherwise exhaust Node's heap.
51
+ - Optional **local-LLM semantic enrichment** that teaches the index what each core component *does*, so conceptual queries match code that shares none of their words.
39
52
 
40
53
  **Token comparison:**
41
54
 
@@ -60,39 +73,96 @@ The difference compounds across a session. Agents that read files also lack topo
60
73
 
61
74
  ## Results
62
75
 
63
- Measured across 5 production open-source codebases (7,503 AST chunks, 70 ground-truth queries across 3 difficulty levels).
76
+ Measured across 5 production open-source codebases (8,296 AST chunks, 100 ground-truth queries:
77
+ 69 **symbolic** — the developer names or paraphrases the symbol — and 31 **semantic** — agent-style
78
+ behavioural descriptions sharing few or no words with the code).
64
79
 
65
- ### Token savings per query
66
-
67
- | Project | Language | Without (full files) | With graph-indexer | Savings | Latency |
68
- | :--- | :--- | ---: | ---: | ---: | ---: |
69
- | Axios v1.6.0 | JavaScript | ~2,290 tok | ~655 tok | **71.4%** | 0.2 ms |
70
- | Express 4.18.2 | JavaScript | ~7,330 tok | ~850 tok | **88.4%** | 0.2 ms |
71
- | NestJS v10.4.9 | TypeScript | ~2,535 tok | ~900 tok | **64.5%** | 0.7 ms |
72
- | FastAPI 0.103.0 | Python | ~4,030 tok | ~685 tok | **83.0%** | 1.0 ms |
73
- | Gin v1.9.1 | Go | ~2,530 tok | ~450 tok | **82.2%** | 0.4 ms |
74
- | **Mean** | | | | **77.9%** | **0.5 ms** |
80
+ * **Average Token Savings:** 79.0%
81
+ * **Strict Success@5 (Hybrid + Rerank):** 82%
82
+ * **Strict MRR (Symbolic queries):** 0.84
75
83
 
76
- > "Full files" = tokens in the source files containing the top-5 results. "With graph-indexer" = tokens in the returned chunk snippets only. Measured at 4 chars/token. NestJS is 700k tokens of source — larger than most LLMs' context windows.
84
+ ### Token savings per query
77
85
 
78
- ### Search quality
86
+ | Project | Language | Chunks | Recall@5 (loose) | Savings |
87
+ | :--- | :--- | ---: | ---: | ---: |
88
+ | Axios v1.6.0 | JavaScript | 450 | 0.89 | **69.7%** |
89
+ | Express 4.18.2 | JavaScript | 389 | 0.95 | **88.7%** |
90
+ | NestJS v10.4.9 | TypeScript | 2,675 | 0.95 | **67.5%** |
91
+ | FastAPI 0.103.0 | Python | 3,694 | 0.76 | **85.5%** |
92
+ | Gin v1.9.1 | Go | 1,088 | 0.94 | **83.7%** |
93
+ | **Mean** | | **8,296** | **0.90** | **79.0%** |
94
+
95
+ > Savings = tokens in the returned chunk snippets vs the full source files containing the top-5
96
+ > results, at 4 chars/token. Search latency is sub-millisecond lexical; the hybrid channel adds
97
+ > one local embedding call plus a streamed vector scan (a few ms at this corpus size).
98
+
99
+ ### Search quality (strict)
100
+
101
+ Strict scoring: a result counts **only** if its symbol name (or its class, for method chunks)
102
+ exactly equals the ground truth — no substring matching, no file-path credit. Hybrid =
103
+ BM25 + `nomic-embed-text` vectors with `qwen2.5-coder:1.5b` enrichment; rerank adds the
104
+ opt-in `qwen2.5-coder:7b` judge on natural-language queries.
105
+
106
+ | Project | Strict success@5 | **Rank-1 (strict)** | MRR (strict) |
107
+ | :--- | ---: | ---: | ---: |
108
+ | Axios v1.6.0 | 0.74 | 0.68 | 0.72 |
109
+ | Express 4.18.2 | 0.90 | 0.62 | 0.73 |
110
+ | NestJS v10.4.9 | 0.67 | 0.57 | 0.60 |
111
+ | FastAPI 0.103.0 | 0.81 | 0.67 | 0.74 |
112
+ | Gin v1.9.1 | 1.00 | 0.78 | 0.84 |
113
+ | **Mean (hybrid + rerank)** | **0.82** | **0.66** | **0.73** |
114
+
115
+ Split by query style and configuration (the numbers that matter for agent workflows):
116
+
117
+ | Channel | Rank-1 | MRR | Success@5 |
118
+ | :--- | ---: | ---: | ---: |
119
+ | **Symbolic** (name-lookup, 69q), hybrid | **0.80** | **0.84** | — |
120
+ | **Semantic** (behavioural, 31q), hybrid + rerank | **0.35** | **0.47** | **0.65** |
121
+ | Semantic, hybrid (no rerank) | 0.23 | 0.37 | 0.55 |
122
+ | Semantic, lexical-only (no Ollama) | 0.19 | 0.29 | 0.48 |
123
+
124
+ Read the semantic rows with their denominator in mind: under *strict* scoring several of those
125
+ queries are unwinnable in-repo (the expected symbol is an anonymous default export, or is
126
+ re-exported from a dependency whose implementation isn't in the codebase), and most strict
127
+ "misses" still land the right file in the top-2 — which the loose channel (0.89) credits and an
128
+ agent can use. Symbol-naming queries — the bulk of real agent traffic — find the exact chunk
129
+ first try 80% of the time and in the top ranks (MRR 0.84) almost always.
130
+
131
+ Ranking favours exact symbol matches even for short, high-signal names — `res.json`,
132
+ `req.get`, `app.all` — by gating the name boost on corpus **document frequency** instead of a
133
+ blunt length cutoff (with singular/plural equivalence, so `BackgroundTask` finds
134
+ `BackgroundTasks`). Natural-language queries are detected and ranked **vector-first** (with the
135
+ name/path boosts gated on semantic agreement), so behavioural questions aren't drowned by
136
+ keyword noise. Test, spec, example and sandbox chunks are demoted in both channels.
137
+
138
+ **Backend parity is exact**: every rank-assigning sort uses deterministic tie-breaking and both
139
+ stores funnel vector candidates through one shared finalizer, so the in-memory engine and the
140
+ SQLite store return **identical top-5 chunk ids for 100/100 benchmark queries** — verified, not
141
+ approximate.
142
+
143
+ Reproduce every view:
79
144
 
80
- | Project | Chunks | Recall@1 | Recall@3 | Recall@5 | MRR |
81
- | :--- | ---: | ---: | ---: | ---: | ---: |
82
- | Axios v1.6.0 | 435 | 0.86 | 1.00 | **1.00** | 0.92 |
83
- | Express 4.18.2 | 389 | 0.93 | 0.93 | **1.00** | 0.95 |
84
- | NestJS v10.4.9 | 2,019 | 1.00 | 1.00 | **1.00** | **1.00** |
85
- | FastAPI 0.103.0 | 3,572 | 0.93 | 1.00 | **1.00** | 0.96 |
86
- | Gin v1.9.1 | 1,088 | 0.85 | 1.00 | **1.00** | 0.92 |
87
- | **Mean** | **7,503** | **0.91** | **0.99** | **1.00** | **0.95** |
145
+ ```bash
146
+ npm run test:setup # clone the 5 fixture repos
147
+ npm run test # loose hit-rate + token savings + indexing stats
148
+ npm run test:eval # strict symbol-level accuracy, lexical channel
149
+ OLLAMA_HOST=http://localhost:11434 node test/evaluate.mjs --embeddings # hybrid
150
+ OLLAMA_HOST=http://localhost:11434 node test/evaluate.mjs --embeddings --use-sqlite # parity
151
+ OLLAMA_HOST=http://localhost:11434 RERANK_MODEL=qwen2.5-coder:7b \
152
+ node test/evaluate.mjs --embeddings --rerank # + judge
153
+ ```
88
154
 
89
- **Recall@5 = 1.00** across every project: the correct answer is always in the top 5. **MRR = 0.95**: the correct answer is rank 1 or 2 on average.
155
+ > The strict harness ([test/evaluate.mjs](test/evaluate.mjs)) exists specifically to keep
156
+ > these numbers honest and prevent the tool from being tuned to a friendly benchmark — it
157
+ > reports the inflation gap (loose minus strict) per suite so regressions in real precision
158
+ > can't hide behind a permissive hit-rate, and it splits the symbolic and semantic channels
159
+ > so neither inflates the other.
90
160
 
91
161
  ---
92
162
 
93
163
  ## Getting started
94
164
 
95
- **Requirements:** Node.js v18+ · Ollama (optional, for semantic search)
165
+ **Requirements:** Node.js v18+ · Ollama (optional, for semantic search and enrichment) · Node.js v22.5+ for the optional SQLite backend
96
166
 
97
167
  ### 1. Install
98
168
 
@@ -135,7 +205,20 @@ npm run mcp:index
135
205
  INDEXER_EMBEDDINGS=off npm run mcp:index
136
206
  ```
137
207
 
138
- Lexical-only still achieves Recall@5 = 1.00 on all benchmarks above.
208
+ Lexical-only still scores loose recall@5 = 0.90 and strict symbolic MRR 0.81 on the benchmarks above — only the semantic (behavioural-query) channel needs embeddings.
209
+
210
+ **On a large monorepo, keep retrieval RAM flat with the SQLite backend:**
211
+ ```bash
212
+ npm run mcp:index -- --use-sqlite
213
+ ```
214
+
215
+ **Sharpen conceptual recall with local-LLM enrichment:**
216
+ ```bash
217
+ ollama pull qwen2.5-coder:1.5b
218
+ npm run mcp:index -- --llm-enrichment
219
+ ```
220
+
221
+ The two options compose (`--use-sqlite --llm-enrichment`) and can be made permanent in `.graph-indexer.json` — see [Configuration](#configuration).
139
222
 
140
223
  ### 3. Configure your IDE
141
224
 
@@ -181,12 +264,99 @@ Lexical-only still achieves Recall@5 = 1.00 on all benchmarks above.
181
264
  }
182
265
  ```
183
266
 
267
+ The server reads the backend from `.graph-indexer.json`, so the same launch config works for both the in-memory and SQLite indexes.
268
+
184
269
  ### 4. Add the agent system prompt
185
270
 
186
271
  Copy [PROMPT.md](./PROMPT.md) into your AI agent's system prompt to instruct it to use graph-indexer tools instead of reading files directly.
187
272
 
188
273
  ---
189
274
 
275
+ ## Storage backends
276
+
277
+ The same index, the same tools, the same ranking — two ways to hold the data. The MCP tools are written against a storage contract and never see which backend is active.
278
+
279
+ | Backend | Default | Resident RAM* | Dependencies | Best for |
280
+ | :--- | :--- | ---: | :--- | :--- |
281
+ | **In-memory** | ✅ | ~586 MB | none | Single packages and services; instant cold start. |
282
+ | **SQLite** | `--use-sqlite` | ~79 MB | none — uses Node's built-in `node:sqlite` | Monorepos / 1M+ LOC where holding every chunk in the heap would OOM. |
283
+
284
+ > *Resident set serving the **same** 50,000-chunk corpus, measured in isolated processes by
285
+ > [test/scale.mjs](test/scale.mjs). The in-memory engine keeps every chunk and the full inverted
286
+ > index in the heap, so its footprint grows with the codebase; the SQLite store keeps only the
287
+ > small file-level dependency graph resident and reads chunks, posting lists and vectors from disk
288
+ > on demand, so its footprint stays flat regardless of corpus size — an **87% smaller** resident
289
+ > set here, and the gap widens as the repo grows.
290
+
291
+ The SQLite backend adds **no external dependency**: `node:sqlite` ships inside Node (v22.5+). Lexical BM25 reads from an indexed `postings` table, symbols and call edges from indexed columns, and vectors live in the shared `code-index.embeddings.bin` — point reads are `pread` on demand. Because both backends feed the same fusion-and-boost ranker with deterministic tie-breaking, switching is purely an operational choice with **zero** quality trade-off (identical top-5 ids on the full benchmark).
292
+
293
+ ### Vector search that stays fast at monorepo scale
294
+
295
+ Below 10k vectors the semantic channel runs an exact streaming scan of the bin (~20 ms worst
296
+ case). Above it, a **binary-quantized sketch** takes over: each vector's sign bits (768 dims →
297
+ 96 bytes, 0.1 % of the float data) are kept in RAM; a query does a Hamming-distance pass over
298
+ packed Uint32 words, then exact-cosine rescores only the top candidates from disk. Measured on
299
+ synthetic corpora (warm):
300
+
301
+ | Corpus | Exact scan | Sketch | Speedup | Sketch RAM |
302
+ | ---: | ---: | ---: | ---: | ---: |
303
+ | 50,000 vectors | 104 ms | **5–11 ms** | ~20× | ~9 MB |
304
+ | 200,000 vectors | 519 ms | **11 ms** | ~36× | ~35 MB |
305
+
306
+ The sketch recovers the exact top-10 (10/10 in validation) because it rescores 2× the candidate
307
+ budget with true cosine before ranking. It is **append-aware**: daemon updates extend it by
308
+ scanning only the unseen tail of the bin, and a full re-index is detected by fingerprint and
309
+ triggers a rebuild. `test/scale.mjs` asserts hybrid queries stay interactive (<60 ms) at 50k
310
+ chunks on both backends.
311
+
312
+ ```bash
313
+ npm run mcp:index -- --use-sqlite # writes code-index.db
314
+ ```
315
+
316
+ ### Live updates on both backends
317
+
318
+ The watch daemon keeps **whichever backend is configured** fresh, incrementally:
319
+
320
+ - **In-memory** — the daemon rewrites the JSON snapshot; a running MCP server watches the file and reloads it automatically.
321
+ - **SQLite** — each file save becomes one WAL transaction (`applyFileUpdate`): the file's old chunks, postings and call edges are replaced with exact BM25 bookkeeping, and new vectors are *appended* to the embeddings bin (O(changed chunks), never a full rewrite). Running MCP servers detect the commit via `PRAGMA data_version` on their next query and refresh themselves — no re-indexing, no restarts.
322
+
323
+ Daemon startup is also O(changed files): files older than the index artifact are skipped during the initial scan, so edits made while the daemon was down are picked up without re-parsing the whole repo.
324
+
325
+ ---
326
+
327
+ ## Semantic enrichment
328
+
329
+ Embeddings match text proximity, not intent. A query like *"payment gateway webhook bottleneck"* misses the function that handles it if that function never spells out those words. Enrichment closes that gap.
330
+
331
+ With `--llm-enrichment`, the indexer routes every substantive **production-source chunk** (tests, specs and example trees are excluded — agents search for implementations) through a local LLM via Ollama, producing per chunk:
332
+
333
+ - a one-line **summary** in developer vocabulary, and
334
+ - a set of **concept tags** (e.g. `authentication, JWT, middleware`).
335
+
336
+ These ride **three** retrieval paths: the tags join the chunk's BM25 lexical document as high-IDF domain terms; the summary leads the code-payload embedding; and — decisive for natural-language queries — each enriched chunk gets a **second, summary-only vector**. A one-line behavioural query embeds far closer to a one-line summary than to hundreds of characters of code, so conceptual searches hit code that shares none of their words.
337
+
338
+ Enrichment is **incremental**: results are cached in `code-index.enrichment.json` keyed by content hash, so a re-index only sends new or changed code to the LLM (the embedding cache keys account for enrichment too, so nothing is re-embedded needlessly). Each run enriches up to `maxChunks` new chunks, ordered by file centrality (PageRank), and coverage accumulates across runs. The watch daemon re-attaches cached enrichment on every file save and live-enriches changed chunks. Generation is best-effort — if the model is unreachable the index is built without enrichment rather than failing.
339
+
340
+ ```bash
341
+ ollama pull qwen2.5-coder:1.5b
342
+ npm run mcp:index -- --llm-enrichment
343
+ ```
344
+
345
+ The model, the file-selection ratio and the per-run cap are all configurable (see [Configuration](#configuration)). Enrichment is fully optional and leaves the default index byte-for-byte unchanged when disabled.
346
+
347
+ ### LLM reranking (query time, opt-in)
348
+
349
+ Fusion resolves most queries, but a natural-language query can end in a near-tie between the
350
+ semantically right chunk and a lexically similar neighbour — a gap no static boost can close.
351
+ With `rerank.enabled` (or `search_code(..., rerank: true)`), the fused top-8 is shown to a local
352
+ LLM judge — one line per candidate — which reorders them. Measured effect on the strict semantic
353
+ channel: **rank-1 0.23 → 0.35, MRR 0.37 → 0.47, success@5 0.55 → 0.65**, with the symbolic
354
+ channel untouched (the judge only fires on natural-language queries, never on symbol lookups or
355
+ `exact_tokens` calls). Cost: one generation call (~1–2 s with `qwen2.5-coder:7b`). Best-effort —
356
+ any model failure preserves the original order.
357
+
358
+ ---
359
+
190
360
  ## MCP tools
191
361
 
192
362
  ### `search_code`
@@ -202,6 +372,7 @@ Hybrid BM25 + vector search over the index. The primary entry point for agents.
202
372
  | `include_topology` | boolean | `true` | Include imports / used-by / calls |
203
373
  | `min_score` | number | `0.3` | Minimum relevance threshold |
204
374
  | `token_budget` | number? | auto | Cap total tokens returned |
375
+ | `rerank` | boolean? | config | LLM-judge rerank for natural-language queries (see `rerank.*` config) |
205
376
 
206
377
  **`detail` levels:**
207
378
 
@@ -211,6 +382,8 @@ Hybrid BM25 + vector search over the index. The primary entry point for agents.
211
382
  | `"smart"` (default) | ~150 tok | Signature + lines relevant to the query |
212
383
  | `"full"` | ~300 tok | Signature + complete source body |
213
384
 
385
+ For purely conceptual queries with no lexical overlap, `"smart"` falls back to a structural skeleton — control-flow lines and call sites — so the agent always gets meaningful signal about what the code does, never a blind truncation.
386
+
214
387
  ---
215
388
 
216
389
  ### `resolve_symbol`
@@ -238,9 +411,10 @@ Returns the source code of a single chunk by its ID.
238
411
 
239
412
  Signature + docstring + calls without the body. ~50 tokens vs ~300 for full.
240
413
 
241
- | Parameter | Type | Description |
242
- | :--- | :--- | :--- |
243
- | `chunk_id` | string | ID from `search_code` results |
414
+ | Parameter | Type | Default | Description |
415
+ | :--- | :--- | :--- | :--- |
416
+ | `chunk_id` | string | required | ID from `search_code` results |
417
+ | `expand_calls` | boolean | `false` | Resolve each outgoing call's signature inline (~150 tok) instead of issuing a follow-up tool call per dependency |
244
418
 
245
419
  ---
246
420
 
@@ -280,7 +454,7 @@ Compact symbol map ordered by PageRank (most-imported files first). Orients agen
280
454
 
281
455
  ### `list_index_stats`
282
456
 
283
- Index health snapshot: chunk count, file count, symbol table size, vector count, search mode, daemon status, index age.
457
+ Index health snapshot: storage backend, chunk count, file count, symbol table size, vector count, search mode, daemon status, index age.
284
458
 
285
459
  ---
286
460
 
@@ -292,32 +466,60 @@ Full bidirectional dependency topology for a file: what it imports and what impo
292
466
 
293
467
  ## Configuration
294
468
 
295
- ### Environment variables
469
+ ### `.graph-indexer.json`
296
470
 
297
- | Variable | Default | Description |
298
- | :--- | :--- | :--- |
299
- | `MCP_PROJECT_ROOT` | `process.cwd()` | Project root directory |
300
- | `OLLAMA_HOST` | `http://localhost:11434` | Ollama API endpoint |
301
- | `INDEXER_EMBEDDINGS` | — | Set to `off` to disable vector embeddings |
302
-
303
- ### Language selection
304
-
305
- `init` saves your selection to `.graph-indexer.json` in the project root:
471
+ All persistent settings live in one file at the project root, written by `init` and read by the indexer, watcher and server:
306
472
 
307
473
  ```json
308
- { "languages": ["typescript", "javascript", "python"] }
474
+ {
475
+ "languages": ["typescript", "javascript", "python"],
476
+ "storage": "sqlite",
477
+ "enrichment": {
478
+ "enabled": true,
479
+ "model": "qwen2.5-coder:1.5b",
480
+ "coreRatio": 0.15,
481
+ "maxChunks": 400
482
+ }
483
+ }
309
484
  ```
310
485
 
311
- Valid keys: `typescript`, `javascript`, `python`, `go`, `rust`, `php`, `java`, `kotlin`, `csharp`, `ruby`, `css`.
486
+ | Key | Default | Description |
487
+ | :--- | :--- | :--- |
488
+ | `languages` | all installed | Parsers to load. Valid keys: `typescript`, `javascript`, `python`, `go`, `rust`, `php`, `java`, `kotlin`, `csharp`, `ruby`, `css`. |
489
+ | `storage` | `"memory"` | `"memory"` (in-heap, zero-dependency) or `"sqlite"` (disk-backed). |
490
+ | `ollamaHost` | `"http://localhost:11434"` | Ollama endpoint (also settable via `OLLAMA_HOST`). |
491
+ | `embedModel` | `"nomic-embed-text"` | Embedding model (index and queries must use the same one). |
492
+ | `enrichment.enabled` | `false` | Run local-LLM enrichment during indexing. |
493
+ | `enrichment.model` | `"qwen2.5-coder:1.5b"` | Ollama model used for generation. |
494
+ | `enrichment.coreRatio` | `1.0` | Share of production files eligible (by PageRank). `1.0` = all; tests/examples are always excluded. |
495
+ | `enrichment.maxChunks` | `500` | Cap on **new** LLM calls per index run — the cache accumulates coverage across runs. |
496
+ | `enrichment.concurrency` | `12` | Parallel Ollama requests during enrichment. |
497
+ | `rerank.enabled` | `false` | LLM-judge reranking of natural-language queries (+50% semantic rank-1, ~1–2 s per NL query). |
498
+ | `rerank.model` | `"qwen2.5-coder:7b"` | Judge model. Quality matters: 7B measured a large gain where 1.5B measured ~none. |
499
+ | `rerank.topM` | `8` | Fused results shown to the judge (8 measured better than 10). |
500
+
501
+ ### CLI flags
502
+
503
+ Flags override the config file for a single run:
504
+
505
+ | Flag | Equivalent |
506
+ | :--- | :--- |
507
+ | `--repo <dir>` | Index a directory other than the cwd |
508
+ | `--use-sqlite` | `"storage": "sqlite"` |
509
+ | `--llm-enrichment` | `"enrichment.enabled": true` |
510
+ | `--enrich-model <name>` | `"enrichment.model"` |
511
+ | `--enrich-max <n>` | `"enrichment.maxChunks"` for this run |
512
+ | `--enrich-concurrency <n>` | `"enrichment.concurrency"` for this run |
312
513
 
313
- Update at any time by re-running `init`:
514
+ ### Environment variables
314
515
 
315
- ```bash
316
- npx graph-indexer init # Re-run interactive selector
317
- npx graph-indexer init --all-languages # Enable all, no prompt
318
- ```
516
+ | Variable | Default | Description |
517
+ | :--- | :--- | :--- |
518
+ | `MCP_PROJECT_ROOT` | `process.cwd()` | Project root directory |
519
+ | `OLLAMA_HOST` | `http://localhost:11434` | Ollama API endpoint |
520
+ | `INDEXER_EMBEDDINGS` | — | Set to `off` to disable vector embeddings |
319
521
 
320
- If `.graph-indexer.json` is absent or has no `languages` key, all installed parsers are used.
522
+ Re-run `init` at any time to change languages, or `init --all-languages` to enable every installed parser without the prompt.
321
523
 
322
524
  ---
323
525
 
@@ -337,23 +539,52 @@ If `.graph-indexer.json` is absent or has no `languages` key, all installed pars
337
539
  | PHP | `.php` | function, class |
338
540
  | CSS / SCSS | `.css`, `.scss` | rule sets |
339
541
 
542
+ Oversized "god classes" are split automatically: a class longer than ~200 lines is indexed as a compact skeleton header plus one independently searchable chunk per method, so a single `get_chunk` never blows the token budget and every method stays reachable by search.
543
+
340
544
  ---
341
545
 
342
546
  ## How it works
343
547
 
548
+ ### Architecture
549
+
550
+ graph-indexer is organised around a small set of cohesive modules with a strict separation between *retrieval math*, *storage*, and *transport*:
551
+
552
+ ```
553
+ config.mjs Resolves CLI flags > env > .graph-indexer.json into one config.
554
+ parser-utils.mjs Tree-sitter parsing, chunk extraction, Ollama embeddings.
555
+ search-core.mjs Shared retrieval math: tokenisation, BM25, RRF fusion + boosts
556
+ (query-adaptive NL weighting), PageRank, embedding cache keys.
557
+ The numbers are measured once here and reused.
558
+ core-engine.mjs MemoryGraphIndex — the default in-heap store — plus the
559
+ embeddings-bin codecs (write/read/append/scan) and the
560
+ binary-quantized vector sketch (Hamming prefilter + rescore).
561
+ sqlite-store.mjs SqliteGraphStore — the disk-backed store (node:sqlite) with
562
+ per-file incremental writes and data_version live refresh.
563
+ storage.mjs createStore(config) — picks a backend; documents the contract
564
+ both implement (searchHybrid, getChunk, applyFileUpdate, …).
565
+ enrichment.mjs Optional LLM summaries + concept tags, cached by content hash.
566
+ mcp-tools.mjs The eight tools, written against the storage contract only.
567
+ mcp-server.mjs Thin bootstrap: config → store → tools → stdio.
568
+ indexer.mjs Bootstrap indexer. watch-daemon.mjs Incremental updates.
569
+ ```
570
+
571
+ Because both stores call the identical `fuseAndRank` from `search-core`, the in-memory and SQLite backends are rank-consistent by construction, and a single change to the ranking math applies everywhere.
572
+
344
573
  ### Indexing
345
574
 
346
575
  When you run `npm run mcp:index`:
347
576
 
348
577
  1. **Parses** every source file with Tree-sitter — no regex, exact AST boundaries.
349
- 2. **Extracts chunks**: named functions, classes, methods, and exports, each with name, docstring, parameters, return type, call sites, and type references.
578
+ 2. **Extracts chunks**: named functions, classes, methods, and exports — including Go/Rust `type`/`struct` declarations by their real name — each with docstring, parameters, return type, call sites, type references, **decorators/annotations** (`@Controller`, `@Injectable`, `@app.route`), and **inheritance edges** (`extends`/`implements`/base classes).
350
579
  3. **Builds a dependency graph**: bidirectional import map so each chunk knows what it imports and what imports it.
351
- 4. **Creates two indexes**: a BM25 inverted index (lexical) and per-chunk float32 embeddings via Ollama `nomic-embed-text` (optional).
352
- 5. **Serializes to disk**: `code-index.json` + `code-index.embeddings.bin`.
580
+ 4. **(Optional) Enriches** production-source chunks with LLM summaries + concept tags (cache-first; only new code pays an LLM call).
581
+ 5. **Creates two indexes**: a BM25 inverted index (lexical) and per-chunk float32 embeddings via Ollama `nomic-embed-text` (optional) — two vectors per enriched chunk (code payload + summary-only).
582
+ 6. **Persists** to the configured backend: `code-index.json` + `code-index.embeddings.bin`, or `code-index.db` + the same embeddings binary.
353
583
 
354
- A background watcher daemon re-indexes changed files automatically. It respects
355
- `.gitignore` and skips `node_modules`, build output, and dot-directories, so it
356
- never traverses (or exhausts OS file-watcher limits on) dependency trees.
584
+ A background watcher daemon applies changed files incrementally to **either** backend (JSON
585
+ snapshot rewrites for in-memory, per-file WAL transactions for SQLite). It respects
586
+ `.gitignore` and skips `node_modules`, build output, and dot-directories, so it never traverses
587
+ (or exhausts OS file-watcher limits on) dependency trees.
357
588
 
358
589
  ### Search pipeline
359
590
 
@@ -365,14 +596,20 @@ Tree-sitter AST
365
596
  │
366
597
  ├──► Chunks ──► BM25 inverted index ──┐
367
598
  │ └──► Float32 embeddings ──┤── RRF fusion ──► Ranked results ──► MCP tools
368
- │ │
599
+ │ │ (search-core)
369
600
  └──► Dependency graph ────────────────┘
370
601
  ```
371
602
 
372
- 1. **BM25 lexical search** — O(query_terms) over the inverted index.
373
- 2. **Vector search** — cosine similarity over pre-normalized float32 embeddings.
374
- 3. **RRF fusion** merges both lists (lexical 1.5×, vector 1.0×).
375
- 4. **Score boosts**: exact name match +2.0×, suffix match +1.4×, path token +1.4×, test files demoted.
603
+ 1. **BM25 lexical search** — O(query_terms) over the inverted index / `postings` table.
604
+ 2. **Vector search** — exact cosine below 10k vectors; binary-sketch Hamming prefilter +
605
+ exact rescore above it (5–11 ms at 50k+, see [Storage backends](#storage-backends)). Both
606
+ code and summary vectors compete; hits fold onto their chunk through one shared finalizer.
607
+ 3. **RRF fusion** merges both lists — lexical-led (1.5×/1.0×) for keyword queries,
608
+ vector-led (1.0×/1.6×) for natural-language ones.
609
+ 4. **Score boosts**: exact name match +2.0× (df-gated, plural-aware), suffix match +1.4×,
610
+ path token +1.4×, test/example files demoted; on NL queries, boosts require semantic
611
+ agreement (the chunk must also be a vector candidate).
612
+ 5. **(Opt-in) LLM rerank** of the fused top-8 on natural-language queries.
376
613
 
377
614
  ---
378
615
 
@@ -386,6 +623,8 @@ Tree-sitter AST
386
623
 
387
624
  **Avoid catch-all utility files.** Files that mix unrelated utilities produce weaker per-chunk signals. One responsibility per module indexes more cleanly.
388
625
 
626
+ **Reach for the SQLite backend when the heap gets tight.** If indexing a large monorepo pushes Node toward its memory limit, switch to `--use-sqlite`; retrieval quality is identical and resident memory stops scaling with the codebase.
627
+
389
628
  ---
390
629
 
391
630
  ## Troubleshooting
@@ -394,19 +633,26 @@ Tree-sitter AST
394
633
  - Verify Ollama is running: `curl http://localhost:11434/api/tags`
395
634
  - Use lexical-only mode: `INDEXER_EMBEDDINGS=off npm run mcp:index`
396
635
 
636
+ **Indexing a huge repo runs out of memory**
637
+ - Build with `--use-sqlite` so chunks live on disk instead of the heap
638
+ - Confirm the active backend with `list_index_stats()`
639
+
640
+ **`--use-sqlite` reports node:sqlite is unavailable**
641
+ - The SQLite backend needs Node v22.5+; upgrade Node, or use the default in-memory backend
642
+
397
643
  **Search returns irrelevant results**
398
644
  - Use `exact_tokens` for known symbol names
399
- - Check coverage with `list_index_stats()`
400
- - Increase `top_k` to see more candidates
645
+ - For conceptual queries, index with `--llm-enrichment` and embeddings enabled
646
+ - Check coverage with `list_index_stats()`; increase `top_k` to see more candidates
401
647
 
402
648
  **MCP server won't connect**
403
649
  - Verify `MCP_PROJECT_ROOT` points to the indexed directory
404
- - Check `code-index.json` exists: `ls -la code-index.json`
650
+ - Check the index exists: `ls -la code-index.json` (or `code-index.db`)
405
651
  - Test manually: `npm run mcp:start`
406
652
 
407
653
  **Results are stale after file changes**
408
- - The daemon auto-updates; if stale, run `npm run mcp:index` manually
409
- - Check daemon status with `list_index_stats()`
654
+ - The daemon updates both backends live and running servers refresh automatically; check `list_index_stats()` for daemon status
655
+ - If the daemon isn't running, restart the MCP server (it spawns the daemon) or run `npm run mcp:index` manually
410
656
 
411
657
  ---
412
658
 
@@ -417,18 +663,30 @@ git clone https://github.com/MaquinaTech/graph-indexer.git
417
663
  cd graph-indexer
418
664
  npm install
419
665
  npm run mcp:index # index this repo
420
- npm run test # run test suite (lexical-only)
421
666
  npm run mcp:start # start MCP server
422
667
  ```
423
668
 
669
+ ### Tests
670
+
671
+ | Command | Scope |
672
+ | :--- | :--- |
673
+ | `npm run test` | Integration suite: indexing + loose hit-rate + token savings across 5 fixtures |
674
+ | `npm run test:eval` | Strict symbol-level accuracy (rank-1, precision, nDCG) |
675
+ | `npm run test:unit` | Pure helpers, chunk splitting |
676
+ | `npm run test:sqlite` | SQLite round-trip + rank consistency vs the in-memory engine |
677
+ | `npm run test:enrich` | HyDE enrichment, including the lexically-disjoint rank-1 flip |
678
+ | `npm run test:mcp` | End-to-end MCP server over stdio |
679
+ | `npm run test:scale` | Mock 50k-chunk corpus proving SQLite RAM stays bounded |
680
+ | `npm run test:all` | Every dependency-free suite above, no Ollama required |
681
+
424
682
  ---
425
683
 
426
684
  ## Security
427
685
 
428
- graph-indexer runs locally and is air-gapped by default — its only outbound call
429
- is to a local Ollama endpoint for embeddings (skipped entirely with
430
- `INDEXER_EMBEDDINGS=off`). It never executes the code it indexes, and the index
431
- artifacts contain source snippets, so keep them git-ignored (as `init` configures).
686
+ graph-indexer runs locally and is air-gapped by default — its only outbound calls
687
+ are to a local Ollama endpoint for embeddings and optional enrichment (skipped entirely with
688
+ `INDEXER_EMBEDDINGS=off` and without `--llm-enrichment`). It never executes the code it indexes,
689
+ and the index artifacts contain source snippets, so keep them git-ignored (as `init` configures).
432
690
 
433
691
  See [SECURITY.md](SECURITY.md) for the full threat model and how to report a
434
692
  vulnerability.