graph-indexer 1.0.0 → 1.0.2

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/core-engine.mjs CHANGED
@@ -3,58 +3,46 @@
3
3
  * @description In-Memory Graph Indexer Core Engine.
4
4
  * @author MaquinaTech <https://github.com/MaquinaTech>
5
5
  * @copyright (c) 2026 MaquinaTech. All rights reserved.
6
- * @license GPL-3.0-only
7
- * * This program is free software: you can redistribute it and/or modify
8
- * it under the terms of the GNU General Public License as published by
9
- * the Free Software Foundation, either version 3 of the License, or
10
- * (at your option) any later version.
11
- *
12
- * This program is distributed in the hope that it will be useful,
13
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
14
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15
- * GNU General Public License for more details.
6
+ * @license MIT
16
7
  */
17
8
  import fs from 'fs';
18
9
 
19
10
  export const EMBEDDING_CONTEXT_LIMIT = 8000;
20
11
 
21
- // Optional HNSW accelerator for large corpora (≥ HNSW_THRESHOLD vectors).
22
- // Falls back silently to exact flat scan if the package is not installed.
23
- const HNSW_THRESHOLD = 5000;
12
+ // Thresholds for adaptive vector loading strategy
13
+ const HNSW_THRESHOLD = 5000; // Build HNSW index above this (eager mode only)
14
+ const LAZY_VEC_THRESHOLD = 10000; // Switch to lazy (disk-backed) vector access above this
15
+ const TFIDF_PREFILTER_K = 2000; // TF-IDF candidates fed into lazy vector scoring
16
+
17
+ // Optional HNSW accelerator for medium corpora (eager mode only)
24
18
  let HierarchicalNSW = null;
25
19
  try {
26
20
  const mod = await import('hnswlib-node');
27
21
  HierarchicalNSW = mod.HierarchicalNSW ?? mod.default?.HierarchicalNSW ?? null;
28
- } catch { /* not installed — flat scan will be used */ }
22
+ } catch { /* not installed — flat scan used */ }
29
23
 
30
24
  export function truncateForEmbedding(text) {
31
25
  return text.length > EMBEDDING_CONTEXT_LIMIT ? text.slice(0, EMBEDDING_CONTEXT_LIMIT) : text;
32
26
  }
33
27
 
34
28
  export function cosineSimilarity(vecA, vecB) {
35
- let dotProduct = 0, normA = 0, normB = 0;
29
+ let dot = 0, nA = 0, nB = 0;
36
30
  for (let i = 0; i < vecA.length; i++) {
37
- dotProduct += vecA[i] * vecB[i];
38
- normA += vecA[i] * vecA[i];
39
- normB += vecB[i] * vecB[i];
31
+ dot += vecA[i] * vecB[i];
32
+ nA += vecA[i] * vecA[i];
33
+ nB += vecB[i] * vecB[i];
40
34
  }
41
- return normA === 0 || normB === 0 ? 0 : dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
35
+ return nA === 0 || nB === 0 ? 0 : dot / (Math.sqrt(nA) * Math.sqrt(nB));
42
36
  }
43
37
 
44
38
  function tokenize(text) {
45
39
  if (!text) return [];
46
40
  const rawTokens = text.split(/[\s\W_]+/);
47
41
  const tokens = [];
48
-
49
42
  for (const word of rawTokens) {
50
- if (word.length < 2) continue; // Skip very short tokens (e.g., single letters)
43
+ if (word.length < 2) continue;
51
44
  tokens.push(word.toLowerCase());
52
-
53
- // Split CamelCase and PascalCase into parts
54
- // E.g.: "TripList" -> "Trip", "List" -> "trip", "list"
55
- // E.g.: "useTripsPage" -> "use", "Trips", "Page" -> "use", "trips", "page"
56
45
  const camelParts = word.replace(/([a-z])([A-Z])/g, '$1 $2').split(' ');
57
-
58
46
  if (camelParts.length > 1) {
59
47
  for (const part of camelParts) {
60
48
  if (part.length >= 2) tokens.push(part.toLowerCase());
@@ -65,16 +53,14 @@ function tokenize(text) {
65
53
  }
66
54
 
67
55
  /**
68
- * Serializes an embeddingCache (Map<hash,Float32Array> or plain object {hash:number[]})
69
- * to a compact binary buffer. Format per entry:
70
- * [uint32 hashLen][utf8 hash][uint32 dim][float32 * dim]
71
- * The buffer is ~4.8× smaller than JSON float text.
56
+ * Serializes embeddingCache to compact binary.
57
+ * Format per entry: [uint32 hashLen][utf8 hash][uint32 dim][float32 × dim]
72
58
  */
73
59
  export function writeEmbeddingBinary(embeddingCache) {
74
60
  const entries = embeddingCache instanceof Map
75
61
  ? Array.from(embeddingCache.entries())
76
62
  : Object.entries(embeddingCache);
77
- let size = 4; // count header
63
+ let size = 4;
78
64
  for (const [hash, vec] of entries) {
79
65
  size += 4 + Buffer.byteLength(hash, 'utf8') + 4 + vec.length * 4;
80
66
  }
@@ -92,60 +78,203 @@ export function writeEmbeddingBinary(embeddingCache) {
92
78
  }
93
79
 
94
80
  export class MemoryGraphIndex {
95
- constructor(indexPath, { rrfK = 60 } = {}) {
81
+ /**
82
+ * @param {string} indexPath
83
+ * @param {object} opts
84
+ * @param {number} opts.rrfK RRF rank discount constant (default 60)
85
+ * @param {boolean} opts.cacheEmbeddings When false, vectors are accessed lazily from disk
86
+ * (ideal for MCP server on large corpora). Default true.
87
+ */
88
+ constructor(indexPath, { rrfK = 60, cacheEmbeddings = true } = {}) {
96
89
  this.indexPath = indexPath;
97
- this.chunks = new Map();
98
- this.vectors = new Map();
99
- this.graph = { dependencies: {}, importedBy: {} };
100
- this.embeddingCache = new Map();
101
- this.rrfK = rrfK;
90
+ this._embeddingPath = indexPath.replace(/\.json$/, '.embeddings.bin');
91
+
92
+ // ── Core data ─────────────────────────────────────────────────────────
93
+ this.chunks = new Map(); // chunkId → chunk metadata
94
+ this.graph = { dependencies: {}, importedBy: {} };
95
+
96
+ // ── Embedding cache (used by indexer to avoid re-embedding) ───────────
97
+ this.embeddingCache = new Map(); // hash → Float32Array
98
+
99
+ // ── Frontier 2: Symbol table ──────────────────────────────────────────
100
+ this.symbolTable = new Map(); // nameLower → Set<chunkId>
101
+
102
+ // ── Lexical search: TRUE inverted index (BM25 scoring) ───────────────
103
+ // invertedIndex: Map<token, Map<chunkId, rawCount>> ← O(1) per-token lookup
104
+ // chunkTerms: Map<chunkId, Set<token>> ← for efficient removal
105
+ // docLens: Map<chunkId, number> ← token count for BM25 length norm
106
+ this.invertedIndex = new Map();
107
+ this.chunkTerms = new Map();
108
+ this.docLens = new Map(); // chunkId → token count (BM25 length normalization)
109
+ this.totalDocLen = 0; // Σ doc lengths for avgdl
110
+ this.pathTokens = new Map(); // chunkId → Set<token> from file path (not in BM25 index)
102
111
  this.docCount = 0;
103
- this.df = new Map();
104
- this.tf = new Map();
105
- this._saveTimer = null;
106
- // Flat vector matrix for O(N·d) exact cosine search — no N-API overhead
112
+ this.df = new Map(); // token → document frequency
113
+
114
+ // ── Vector search: eager mode (small corpora) ─────────────────────────
115
+ this.vectors = new Map(); // chunkId → Float32Array (eager only)
107
116
  this._matrixDirty = true;
108
- this._vecMatrix = null; // Float32Array(N × dim)
109
- this._vecNorms = null; // Float32Array(N) — pre-computed L2 norms
110
- this._vecIds = []; // string[N] — row-index → chunk-id lookup
111
- this._dim = 0;
112
- this._hnsw = null; // HierarchicalNSW instance (optional large-repo accelerator)
113
- this._embeddingPath = indexPath.replace(/\.json$/, '.embeddings.bin');
117
+ this._vecMatrix = null; // Float32Array(N × dim)
118
+ this._vecNorms = null; // Float32Array(N)
119
+ this._vecIds = []; // row index → chunkId
120
+ this._dim = 0;
121
+ this._hnsw = null;
122
+
123
+ // ── Vector search: lazy mode (large corpora, cacheEmbeddings=false) ───
124
+ this._cacheEmbeddings = cacheEmbeddings;
125
+ this._vecOffsets = new Map(); // hash → { offset: number, dim: number }
126
+ this._embeddingBuf = null; // Buffer kept for small-lazy corpora (<50k)
127
+ this._vecFd = -1; // open fd for disk-backed access (≥50k)
128
+ this._lazyMode = false;
129
+
130
+ this.rrfK = rrfK;
131
+ this._saveTimer = null;
114
132
  }
115
133
 
134
+ // ─── Load ─────────────────────────────────────────────────────────────────
135
+
116
136
  load() {
117
137
  if (!fs.existsSync(this.indexPath)) return;
118
138
  const data = JSON.parse(fs.readFileSync(this.indexPath, 'utf-8'));
119
139
  this.graph = data.graph || { dependencies: {}, importedBy: {} };
120
140
 
121
- // Prefer binary sidecar (~4.8× smaller); fall back to JSON embeddingCache for old indexes
141
+ const chunkCount = (data.chunks || []).length;
142
+
143
+ // Decide loading strategy based on corpus size and cacheEmbeddings flag
144
+ this._lazyMode = (!this._cacheEmbeddings) && chunkCount >= LAZY_VEC_THRESHOLD;
145
+
122
146
  if (fs.existsSync(this._embeddingPath)) {
123
- this._loadEmbeddingBinary(fs.readFileSync(this._embeddingPath));
124
- } else {
125
- for (const [hash, vec] of Object.entries(data.embeddingCache || {})) {
147
+ const binBuf = fs.readFileSync(this._embeddingPath);
148
+ if (this._lazyMode) {
149
+ this._buildVecOffsets(binBuf);
150
+ // For very large corpora: open persistent fd, release buffer from heap
151
+ if (chunkCount >= 50000) {
152
+ try { this._vecFd = fs.openSync(this._embeddingPath, 'r'); } catch { this._vecFd = -1; }
153
+ // binBuf goes out of scope → GC can collect it
154
+ } else {
155
+ // Medium-large: keep buffer for zero-copy slice access
156
+ this._embeddingBuf = binBuf;
157
+ }
158
+ } else {
159
+ // Eager: fill embeddingCache (needed by indexer + small-corpus MCP server)
160
+ this._loadEmbeddingBinary(binBuf);
161
+ }
162
+ } else if (data.embeddingCache) {
163
+ for (const [hash, vec] of Object.entries(data.embeddingCache)) {
126
164
  this.embeddingCache.set(hash, new Float32Array(vec));
127
165
  }
128
166
  }
129
167
 
130
- for (const chunk of data.chunks) {
168
+ for (const chunk of (data.chunks || [])) {
131
169
  this.chunks.set(chunk.id, chunk);
132
- if (chunk.content_hash && this.embeddingCache.has(chunk.content_hash)) {
133
- this.vectors.set(chunk.id, this.embeddingCache.get(chunk.content_hash));
134
- } else if (chunk.embedding) {
135
- const vec = new Float32Array(chunk.embedding);
136
- this.vectors.set(chunk.id, vec);
137
- if (chunk.content_hash) this.embeddingCache.set(chunk.content_hash, vec);
170
+
171
+ // Eager vector population
172
+ if (!this._lazyMode) {
173
+ if (chunk.content_hash && this.embeddingCache.has(chunk.content_hash)) {
174
+ this.vectors.set(chunk.id, this.embeddingCache.get(chunk.content_hash));
175
+ } else if (chunk.embedding) {
176
+ const vec = new Float32Array(chunk.embedding);
177
+ this.vectors.set(chunk.id, vec);
178
+ if (chunk.content_hash) this.embeddingCache.set(chunk.content_hash, vec);
179
+ }
138
180
  }
139
181
 
182
+ // Build inverted lexical index
140
183
  const deps = this.graph.dependencies[chunk.file_path] || [];
141
184
  const cleanDeps = deps.map(d => d.split('/').pop().split('.')[0]);
185
+ const enrichedContext = [
186
+ chunk.name,
187
+ chunk.docstring || '',
188
+ cleanDeps.join(' '),
189
+ (chunk.calls || []).join(' '),
190
+ (chunk.params || []).join(' '),
191
+ chunk.return_type || '',
192
+ chunk.class_context ? `${chunk.class_context}.${chunk.name}` : '',
193
+ (chunk.type_refs || []).join(' '),
194
+ chunk.code_snippet
195
+ ].join(' ');
196
+ this._indexLexical(chunk.id, enrichedContext, chunk.file_path);
197
+
198
+ // Build symbol table (Frontier 2)
199
+ if (chunk.name && chunk.name !== 'anonymous') {
200
+ const n = chunk.name.toLowerCase();
201
+ if (!this.symbolTable.has(n)) this.symbolTable.set(n, new Set());
202
+ this.symbolTable.get(n).add(chunk.id);
203
+ }
204
+ }
205
+ }
206
+
207
+ // ─── Embedding binary helpers ──────────────────────────────────────────────
142
208
 
143
- const enrichedContext = `${chunk.name} ${chunk.docstring || ''} ${cleanDeps.join(' ')} ${(chunk.calls || []).join(' ')} ${chunk.code_snippet}`;
144
- this._indexLexical(chunk.id, enrichedContext);
209
+ _loadEmbeddingBinary(buf) {
210
+ let off = 0;
211
+ const count = buf.readUInt32LE(off); off += 4;
212
+ for (let i = 0; i < count; i++) {
213
+ const hashLen = buf.readUInt32LE(off); off += 4;
214
+ const hash = buf.subarray(off, off + hashLen).toString('utf8'); off += hashLen;
215
+ const dim = buf.readUInt32LE(off); off += 4;
216
+ const vec = new Float32Array(dim);
217
+ for (let d = 0; d < dim; d++) { vec[d] = buf.readFloatLE(off); off += 4; }
218
+ this.embeddingCache.set(hash, vec);
145
219
  }
146
220
  }
147
221
 
148
- _indexLexical(chunkId, text) {
222
+ /** Build offset table without creating Float32Array objects. */
223
+ _buildVecOffsets(buf) {
224
+ this._vecOffsets = new Map();
225
+ let off = 0;
226
+ const count = buf.readUInt32LE(off); off += 4;
227
+ for (let i = 0; i < count; i++) {
228
+ const hashLen = buf.readUInt32LE(off); off += 4;
229
+ const hash = buf.subarray(off, off + hashLen).toString('utf8'); off += hashLen;
230
+ const dim = buf.readUInt32LE(off); off += 4;
231
+ this._vecOffsets.set(hash, { offset: off, dim });
232
+ off += dim * 4; // skip float data — never loaded into JS heap
233
+ }
234
+ }
235
+
236
+ /** Return Float32Array for a chunk, using in-memory buffer or disk fd. */
237
+ _getVecForChunk(chunkId) {
238
+ const chunk = this.chunks.get(chunkId);
239
+ if (!chunk?.content_hash) return null;
240
+ const entry = this._vecOffsets.get(chunk.content_hash);
241
+ if (!entry) return null;
242
+
243
+ if (this._embeddingBuf) {
244
+ // Buffer-backed: zero-copy aligned view or fallback copy
245
+ const byteStart = entry.offset;
246
+ if ((this._embeddingBuf.byteOffset + byteStart) % 4 === 0) {
247
+ return new Float32Array(
248
+ this._embeddingBuf.buffer,
249
+ this._embeddingBuf.byteOffset + byteStart,
250
+ entry.dim
251
+ );
252
+ }
253
+ const vec = new Float32Array(entry.dim);
254
+ for (let d = 0; d < entry.dim; d++)
255
+ vec[d] = this._embeddingBuf.readFloatLE(byteStart + d * 4);
256
+ return vec;
257
+ }
258
+
259
+ if (this._vecFd >= 0) {
260
+ // Disk-backed: single pread syscall
261
+ const byteLen = entry.dim * 4;
262
+ const raw = Buffer.allocUnsafe(byteLen);
263
+ try {
264
+ const read = fs.readSync(this._vecFd, raw, 0, byteLen, entry.offset);
265
+ if (read < byteLen) return null;
266
+ const vec = new Float32Array(entry.dim);
267
+ for (let d = 0; d < entry.dim; d++) vec[d] = raw.readFloatLE(d * 4);
268
+ return vec;
269
+ } catch { return null; }
270
+ }
271
+
272
+ return null;
273
+ }
274
+
275
+ // ─── Lexical index (TRUE inverted index) ──────────────────────────────────
276
+
277
+ _indexLexical(chunkId, text, filePath = '') {
149
278
  const tokens = tokenize(text);
150
279
  if (tokens.length === 0) return;
151
280
 
@@ -154,39 +283,85 @@ export class MemoryGraphIndex {
154
283
  termCounts.set(token, (termCounts.get(token) || 0) + 1);
155
284
  }
156
285
 
157
- const tfMap = new Map();
158
- for (const [term, count] of termCounts.entries()) {
159
- tfMap.set(term, 1 + Math.log(count));
286
+ // Track document length for BM25 length normalization.
287
+ // Only content tokens are counted — path tokens are handled separately
288
+ // in searchHybrid to avoid BM25 length normalisation amplifying short
289
+ // export stubs that share a path with a larger implementation.
290
+ this.docLens.set(chunkId, tokens.length);
291
+ this.totalDocLen += tokens.length;
292
+
293
+ const chunkTokenSet = new Set();
294
+ for (const [term, count] of termCounts) {
295
+ // Store raw term count — BM25 applies saturation at search time
160
296
  this.df.set(term, (this.df.get(term) || 0) + 1);
297
+ let posting = this.invertedIndex.get(term);
298
+ if (!posting) { posting = new Map(); this.invertedIndex.set(term, posting); }
299
+ posting.set(chunkId, count);
300
+ chunkTokenSet.add(term);
161
301
  }
162
302
 
163
- this.tf.set(chunkId, tfMap);
303
+ this.chunkTerms.set(chunkId, chunkTokenSet);
164
304
  this.docCount++;
305
+
306
+ // ── Path tokens: stored separately from content, not in BM25 index ──────
307
+ // Path matching is done multiplicatively in searchHybrid, which avoids
308
+ // amplifying short stubs that happen to share a path with a long function.
309
+ if (filePath) {
310
+ const pathTokenSet = new Set(tokenize(filePath.replace(/[/\-_.]/g, ' ')));
311
+ if (!this.pathTokens) this.pathTokens = new Map();
312
+ this.pathTokens.set(chunkId, pathTokenSet);
313
+ }
165
314
  }
166
315
 
167
316
  _removeLexical(chunkId) {
168
- const tfMap = this.tf.get(chunkId);
169
- if (!tfMap) return;
170
- for (const term of tfMap.keys()) {
317
+ const terms = this.chunkTerms.get(chunkId);
318
+ if (!terms) return;
319
+ for (const term of terms) {
320
+ const posting = this.invertedIndex.get(term);
321
+ if (posting) {
322
+ posting.delete(chunkId);
323
+ if (posting.size === 0) this.invertedIndex.delete(term);
324
+ }
171
325
  const freq = this.df.get(term);
172
- if (freq === undefined) continue;
173
- if (freq <= 1) this.df.delete(term);
174
- else this.df.set(term, freq - 1);
326
+ if (freq !== undefined) {
327
+ if (freq <= 1) this.df.delete(term);
328
+ else this.df.set(term, freq - 1);
329
+ }
330
+ }
331
+ // Update BM25 length accounting
332
+ const dl = this.docLens.get(chunkId);
333
+ if (dl !== undefined) {
334
+ this.totalDocLen = Math.max(0, this.totalDocLen - dl);
335
+ this.docLens.delete(chunkId);
175
336
  }
176
- this.tf.delete(chunkId);
337
+ this.pathTokens.delete(chunkId);
338
+ this.chunkTerms.delete(chunkId);
177
339
  this.docCount = Math.max(0, this.docCount - 1);
178
340
  }
179
341
 
180
342
  _searchLexical(queryText) {
181
343
  const queryTokens = tokenize(queryText);
182
344
  const scores = new Map();
345
+
346
+ // BM25 parameters tuned for code (b lowered from 0.75 to 0.3 — code chunks
347
+ // have purposeful length variation unlike prose, so heavy length normalization
348
+ // incorrectly penalises long implementations vs short export stubs).
349
+ const k1 = 1.5;
350
+ const b = 0.3;
351
+ const avgdl = this.docCount > 0 ? this.totalDocLen / this.docCount : 1;
352
+
183
353
  for (const token of queryTokens) {
184
354
  const docFreq = this.df.get(token);
185
355
  if (!docFreq) continue;
186
- const idf = Math.log(this.docCount / docFreq);
187
- for (const [chunkId, tfMap] of this.tf.entries()) {
188
- const tf = tfMap.get(token);
189
- if (tf) scores.set(chunkId, (scores.get(chunkId) || 0) + tf * idf);
356
+ // Okapi IDF — always positive, avoids negative IDF for very common terms
357
+ const idf = Math.log((this.docCount - docFreq + 0.5) / (docFreq + 0.5) + 1);
358
+ const posting = this.invertedIndex.get(token);
359
+ if (!posting) continue;
360
+ for (const [chunkId, tf] of posting) {
361
+ const dl = this.docLens.get(chunkId) ?? avgdl;
362
+ // BM25: diminishing returns for high TF + document-length normalisation
363
+ const bm25 = idf * (tf * (k1 + 1)) / (tf + k1 * (1 - b + b * dl / avgdl));
364
+ scores.set(chunkId, (scores.get(chunkId) || 0) + bm25);
190
365
  }
191
366
  }
192
367
  return Array.from(scores.entries())
@@ -194,6 +369,8 @@ export class MemoryGraphIndex {
194
369
  .map(([id, score], rank) => ({ id, score, rank: rank + 1 }));
195
370
  }
196
371
 
372
+ // ─── Vector search ─────────────────────────────────────────────────────────
373
+
197
374
  _invalidateMatrix() {
198
375
  this._matrixDirty = true;
199
376
  this._hnsw = null;
@@ -213,17 +390,14 @@ export class MemoryGraphIndex {
213
390
  const n = ids.length;
214
391
  this._vecIds = ids;
215
392
  if (n === 0) {
216
- this._vecMatrix = null;
217
- this._vecNorms = null;
218
- this._dim = 0;
219
- this._hnsw = null;
220
- this._matrixDirty = false;
393
+ this._vecMatrix = null; this._vecNorms = null;
394
+ this._dim = 0; this._hnsw = null; this._matrixDirty = false;
221
395
  return;
222
396
  }
223
397
  const dim = this.vectors.get(ids[0]).length;
224
398
  this._dim = dim;
225
399
  const matrix = new Float32Array(n * dim);
226
- const norms = new Float32Array(n);
400
+ const norms = new Float32Array(n);
227
401
  for (let i = 0; i < n; i++) {
228
402
  const v = this.vectors.get(ids[i]);
229
403
  let normSq = 0;
@@ -236,10 +410,10 @@ export class MemoryGraphIndex {
236
410
  norms[i] = Math.sqrt(normSq);
237
411
  }
238
412
  this._vecMatrix = matrix;
239
- this._vecNorms = norms;
413
+ this._vecNorms = norms;
240
414
  this._matrixDirty = false;
241
415
 
242
- // Build HNSW index for large corpora when library is available
416
+ // HNSW for large eager corpora
243
417
  if (HierarchicalNSW && n >= HNSW_THRESHOLD) {
244
418
  try {
245
419
  const hnsw = new HierarchicalNSW('cosine', dim);
@@ -253,28 +427,27 @@ export class MemoryGraphIndex {
253
427
  }
254
428
  this._hnsw = hnsw;
255
429
  } catch (e) {
256
- process.stderr.write(`[core-engine] HNSW build failed, using flat scan: ${e.message}\n`);
430
+ process.stderr.write(`[core-engine] HNSW build failed: ${e.message}\n`);
257
431
  this._hnsw = null;
258
432
  }
259
433
  }
260
434
  }
261
435
 
436
+ /** Full flat-scan / HNSW search (eager mode). */
262
437
  _searchVector(queryVector, minScore = 0.3) {
263
438
  if (this.vectors.size === 0) return [];
264
439
  if (this._matrixDirty) this._rebuildMatrix();
265
440
  if (!this._vecMatrix) return [];
266
441
  if (queryVector.length !== this._dim) return [];
267
442
 
268
- const n = this._vecIds.length;
443
+ const n = this._vecIds.length;
269
444
  const dim = this._dim;
270
-
271
- // Pre-compute query L2 norm once
272
445
  let qNorm = 0;
273
446
  for (let d = 0; d < dim; d++) qNorm += queryVector[d] * queryVector[d];
274
447
  qNorm = Math.sqrt(qNorm);
275
448
  if (qNorm === 0) return [];
276
449
 
277
- // Large-corpus fast path: approximate nearest-neighbour via HNSW
450
+ // HNSW fast path
278
451
  if (this._hnsw) {
279
452
  const topK = Math.min(200, n);
280
453
  try {
@@ -282,16 +455,14 @@ export class MemoryGraphIndex {
282
455
  const { neighbors, distances } = this._hnsw.searchKnn(qArr, topK);
283
456
  const results = [];
284
457
  for (let i = 0; i < neighbors.length; i++) {
285
- const score = 1 - distances[i]; // cosine distance → similarity
458
+ const score = 1 - distances[i];
286
459
  if (score > minScore) results.push({ id: this._vecIds[neighbors[i]], score, rank: i + 1 });
287
460
  }
288
461
  return results;
289
- } catch {
290
- // HNSW search failed; fall through to exact flat scan
291
- }
462
+ } catch { /* fall through to flat scan */ }
292
463
  }
293
464
 
294
- // Exact brute-force flat scan — O(N·d), no marshalling overhead
465
+ // Exact flat scan
295
466
  const results = [];
296
467
  for (let i = 0; i < n; i++) {
297
468
  const base = i * dim;
@@ -305,27 +476,132 @@ export class MemoryGraphIndex {
305
476
  return results;
306
477
  }
307
478
 
479
+ /**
480
+ * Two-phase vector search (lazy mode):
481
+ * Only compute cosine for candidateIds (top-K TF-IDF pre-filter).
482
+ * Reads vectors on-demand from buffer or disk — no full matrix in RAM.
483
+ */
484
+ _searchVectorCandidates(queryVector, candidateIds, minScore = 0.3) {
485
+ const qDim = queryVector.length;
486
+ let qNorm = 0;
487
+ for (let d = 0; d < qDim; d++) qNorm += queryVector[d] * queryVector[d];
488
+ qNorm = Math.sqrt(qNorm);
489
+ if (qNorm === 0) return [];
490
+
491
+ const results = [];
492
+ for (const chunkId of candidateIds) {
493
+ const vec = this._getVecForChunk(chunkId);
494
+ if (!vec || vec.length !== qDim) continue;
495
+ let dp = 0, normVec = 0;
496
+ for (let d = 0; d < qDim; d++) {
497
+ dp += queryVector[d] * vec[d];
498
+ normVec += vec[d] * vec[d];
499
+ }
500
+ const normV = Math.sqrt(normVec);
501
+ if (normV === 0) continue;
502
+ const score = dp / (qNorm * normV);
503
+ if (score > minScore) results.push({ id: chunkId, score });
504
+ }
505
+ results.sort((a, b) => b.score - a.score);
506
+ for (let i = 0; i < results.length; i++) results[i].rank = i + 1;
507
+ return results;
508
+ }
509
+
510
+ // ─── Hybrid search ─────────────────────────────────────────────────────────
511
+
308
512
  searchHybrid(queryText, queryVector, topK = 5, minScore = 0.3, exactBoostName = null) {
309
513
  const lexicalResults = this._searchLexical(queryText);
310
- const vectorResults = queryVector ? this._searchVector(queryVector, minScore) : [];
311
- const rrfScores = new Map();
312
- const K = this.rrfK;
313
- const allResults = [...vectorResults, ...lexicalResults];
314
514
 
515
+ let vectorResults;
516
+ if (queryVector) {
517
+ if (this._lazyMode && this._vecOffsets.size > 0) {
518
+ // Two-phase: TF-IDF pre-filter → lazy vector load
519
+ const candidateIds = lexicalResults.slice(0, TFIDF_PREFILTER_K).map(r => r.id);
520
+ vectorResults = this._searchVectorCandidates(queryVector, candidateIds, minScore);
521
+ } else {
522
+ vectorResults = this._searchVector(queryVector, minScore);
523
+ }
524
+ } else {
525
+ vectorResults = [];
526
+ }
527
+
528
+ const rrfScores = new Map();
529
+ const K = this.rrfK;
315
530
  const queryLower = queryText.toLowerCase();
316
- for (const { id, rank } of allResults) {
317
- let baseScore = 1 / (K + rank);
531
+
532
+ const LEXICAL_WEIGHT = 1.5;
533
+ const VECTOR_WEIGHT = 1.0;
534
+
535
+ // Pre-compute query file tokens once for path boosting
536
+ const _queryPathTokens = queryLower.split(/[\s\W_]+/).filter(t => t.length >= 3);
537
+
538
+ const allResults = [
539
+ ...vectorResults.map(r => ({ ...r, _w: VECTOR_WEIGHT })),
540
+ ...lexicalResults.map(r => ({ ...r, _w: LEXICAL_WEIGHT })),
541
+ ];
542
+
543
+ for (const { id, rank, _w } of allResults) {
544
+ let baseScore = (_w ?? 1.0) / (K + rank);
318
545
  const chunk = this.chunks.get(id);
319
546
  if (!chunk) continue;
320
547
 
321
- // Generic: demote test/spec files when the query is not about tests
322
- if (/\.(test|spec)\.|[/\\]__tests__[/\\]|_test\./.test(chunk.file_path)) {
323
- if (!queryLower.includes('test') && !queryLower.includes('spec')) baseScore *= 0.6;
548
+ // Demotion: test / spec files
549
+ if (/\.(test|spec)\.|[/\\]__tests__[/\\]|_test\.|^tests?[/\\]|[/\\]tests?[/\\]|[/\\]spec[/\\]/.test(chunk.file_path)) {
550
+ if (!queryLower.includes('test') && !queryLower.includes('spec')) baseScore *= 0.25;
324
551
  }
325
- // Prefer definitions (functions/classes/impls) over pure usage/expression sites
552
+ // Demotion: example / docs dirs (tutorial snippets over-rank due to short length
553
+ // and high keyword density relative to the actual framework implementation).
554
+ if (/^examples?[/\\]|[/\\]examples?[/\\]|^samples?[/\\]|[/\\]samples?[/\\]|^demos?[/\\]|[/\\]demos?[/\\]|[/\\]tutorials?[/\\]|^docs_src[/\\]|[/\\]docs_src[/\\]/.test(chunk.file_path)) {
555
+ baseScore *= 0.5;
556
+ }
557
+ // Demotion: pure expression sites
326
558
  if (chunk.node_type === 'expression_statement' || chunk.node_type === 'call_expression') {
327
559
  baseScore *= 0.8;
328
560
  }
561
+ // Demotion: TypeScript barrel re-exports (e.g. "controller.decorator_export_statement"
562
+ // in .ts files). These are synthetic chunks created for `export { X } from 'y'`
563
+ // declarations — no implementation, just re-exporting from another file.
564
+ // JavaScript .js exports (e.g. "http_export_statement") are intentionally excluded
565
+ // since those often ARE the primary module export (the file IS the implementation).
566
+ if (chunk.name && chunk.name.endsWith('_export_statement') && chunk.file_path?.endsWith('.ts')) {
567
+ baseScore *= 0.7;
568
+ }
569
+
570
+ // File-path boost: applied to ALL queries (not just when vectors are absent).
571
+ // Uses the separate pathTokens index (not the BM25 inverted index) so that
572
+ // length normalisation does not penalise long implementations sharing a path
573
+ // with short stubs.
574
+ if (_queryPathTokens.length > 0) {
575
+ const pathToks = this.pathTokens.get(id);
576
+ if (pathToks) {
577
+ const hasExact = _queryPathTokens.some(t => pathToks.has(t));
578
+ const hasPrefix = !hasExact && _queryPathTokens.some(t =>
579
+ t.length >= 4 && Array.from(pathToks).some(pt => pt.startsWith(t.slice(0, 5)))
580
+ );
581
+ if (hasExact) baseScore *= 1.4;
582
+ else if (hasPrefix) baseScore *= 1.2;
583
+ }
584
+ }
585
+
586
+ // Name boost:
587
+ // 2.0x exact match — a query token IS the full chunk name.
588
+ // 1.4x snake_case suffix — the last underscore/dot component of the name
589
+ // matches a query token. Intentionally uses only [._] splitting so
590
+ // camelCase names stay atomic: "dispatchRequest" is one token, not
591
+ // ["dispatch","request"], preventing false boosts from generic suffixes.
592
+ if (chunk.name && chunk.name !== 'anonymous') {
593
+ const nameLower = chunk.name.toLowerCase();
594
+ const queryTokensAll = queryLower.split(/[\s\W_]+/).filter(t => t.length >= 5);
595
+ if (queryTokensAll.some(t => nameLower === t || nameLower.endsWith('.' + t))) {
596
+ baseScore *= 2.0;
597
+ } else {
598
+ const snakeParts = nameLower.split(/[._]+/);
599
+ const lastSnake = snakeParts[snakeParts.length - 1] ?? '';
600
+ if (snakeParts.length >= 2 && lastSnake.length >= 5 && queryTokensAll.includes(lastSnake)) {
601
+ baseScore *= 1.4;
602
+ }
603
+ }
604
+ }
329
605
 
330
606
  rrfScores.set(id, (rrfScores.get(id) || 0) + baseScore);
331
607
  }
@@ -346,29 +622,19 @@ export class MemoryGraphIndex {
346
622
  .filter(r => r.chunk !== undefined);
347
623
  }
348
624
 
349
- _loadEmbeddingBinary(buf) {
350
- let off = 0;
351
- const count = buf.readUInt32LE(off); off += 4;
352
- for (let i = 0; i < count; i++) {
353
- const hashLen = buf.readUInt32LE(off); off += 4;
354
- const hash = buf.subarray(off, off + hashLen).toString('utf8'); off += hashLen;
355
- const dim = buf.readUInt32LE(off); off += 4;
356
- const vec = new Float32Array(dim);
357
- for (let d = 0; d < dim; d++) { vec[d] = buf.readFloatLE(off); off += 4; }
358
- this.embeddingCache.set(hash, vec);
359
- }
360
- }
625
+ // ─── Persistence ───────────────────────────────────────────────────────────
361
626
 
362
627
  async save() {
363
628
  const chunksData = Array.from(this.chunks.values()).map(c => ({
364
629
  id: c.id, file_path: c.file_path, node_type: c.node_type,
365
630
  name: c.name, docstring: c.docstring || '', code_snippet: c.code_snippet,
366
631
  content_hash: c.content_hash, start_line: c.start_line, end_line: c.end_line,
367
- calls: c.calls || []
632
+ calls: c.calls || [], params: c.params || [],
633
+ return_type: c.return_type || '', class_context: c.class_context || '',
634
+ type_refs: c.type_refs || [],
368
635
  }));
369
- // Embeddings go to a compact binary sidecar — NOT in the JSON payload
370
- const payload = JSON.stringify({ chunks: chunksData, graph: this.graph });
371
- const tmpPath = `${this.indexPath}.tmp`;
636
+ const payload = JSON.stringify({ chunks: chunksData, graph: this.graph });
637
+ const tmpPath = `${this.indexPath}.tmp`;
372
638
  const tmpBinPath = `${this._embeddingPath}.tmp`;
373
639
  await Promise.all([
374
640
  fs.promises.writeFile(tmpPath, payload),
@@ -402,4 +668,13 @@ export class MemoryGraphIndex {
402
668
  if (!this.graph.importedBy[dep].includes(filePath)) this.graph.importedBy[dep].push(filePath);
403
669
  }
404
670
  }
405
- }
671
+
672
+ // ─── Cleanup ───────────────────────────────────────────────────────────────
673
+
674
+ close() {
675
+ if (this._vecFd >= 0) {
676
+ try { fs.closeSync(this._vecFd); } catch {}
677
+ this._vecFd = -1;
678
+ }
679
+ }
680
+ }