graph-indexer 1.0.1 → 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
@@ -4,57 +4,45 @@
4
4
  * @author MaquinaTech <https://github.com/MaquinaTech>
5
5
  * @copyright (c) 2026 MaquinaTech. All rights reserved.
6
6
  * @license MIT
7
- * Copyright (c) 2026 MaquinaTech. All rights reserved.
8
- * Permission is hereby granted, free of charge, to any person obtaining a copy
9
- * of this software and associated documentation files (the "Software"), to deal
10
- * in the Software without restriction, including without limitation the rights
11
- * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
12
- * copies of the Software, and to permit persons to whom the Software is
13
- * furnished to do so, subject to the following conditions: The above copyright
14
- * notice and this permission notice shall be included in all copies or
15
- * substantial portions of the Software.
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,54 +78,110 @@ 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]);
142
-
143
185
  const enrichedContext = [
144
186
  chunk.name,
145
187
  chunk.docstring || '',
@@ -148,12 +190,90 @@ export class MemoryGraphIndex {
148
190
  (chunk.params || []).join(' '),
149
191
  chunk.return_type || '',
150
192
  chunk.class_context ? `${chunk.class_context}.${chunk.name}` : '',
193
+ (chunk.type_refs || []).join(' '),
151
194
  chunk.code_snippet
152
195
  ].join(' ');
153
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 ──────────────────────────────────────────────
208
+
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);
219
+ }
220
+ }
221
+
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;
154
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;
155
273
  }
156
274
 
275
+ // ─── Lexical index (TRUE inverted index) ──────────────────────────────────
276
+
157
277
  _indexLexical(chunkId, text, filePath = '') {
158
278
  const tokens = tokenize(text);
159
279
  if (tokens.length === 0) return;
@@ -163,52 +283,85 @@ export class MemoryGraphIndex {
163
283
  termCounts.set(token, (termCounts.get(token) || 0) + 1);
164
284
  }
165
285
 
166
- const tfMap = new Map();
167
- for (const [term, count] of termCounts.entries()) {
168
- 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
169
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);
170
301
  }
171
302
 
172
- // 🥇 FILENAME TOKEN BOOST: Add file path tokens with fixed TF weight 1.0 (not sublinear)
173
- // Splits on /, -, _, ., CamelCase so "authStore.ts" → ["auth", "store", "ts", "authstore"]
303
+ this.chunkTerms.set(chunkId, chunkTokenSet);
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.
174
309
  if (filePath) {
175
- const fileTokens = tokenize(filePath.replace(/[\/\-_.]/g, ' '));
176
- for (const token of fileTokens) {
177
- if (!tfMap.has(token)) {
178
- // Only add if not already from the body (avoids double-counting)
179
- tfMap.set(token, 1.0);
180
- this.df.set(token, (this.df.get(token) || 0) + 1);
181
- }
182
- }
310
+ const pathTokenSet = new Set(tokenize(filePath.replace(/[/\-_.]/g, ' ')));
311
+ if (!this.pathTokens) this.pathTokens = new Map();
312
+ this.pathTokens.set(chunkId, pathTokenSet);
183
313
  }
184
-
185
- this.tf.set(chunkId, tfMap);
186
- this.docCount++;
187
314
  }
188
315
 
189
316
  _removeLexical(chunkId) {
190
- const tfMap = this.tf.get(chunkId);
191
- if (!tfMap) return;
192
- 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
+ }
193
325
  const freq = this.df.get(term);
194
- if (freq === undefined) continue;
195
- if (freq <= 1) this.df.delete(term);
196
- 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);
197
336
  }
198
- this.tf.delete(chunkId);
337
+ this.pathTokens.delete(chunkId);
338
+ this.chunkTerms.delete(chunkId);
199
339
  this.docCount = Math.max(0, this.docCount - 1);
200
340
  }
201
341
 
202
342
  _searchLexical(queryText) {
203
343
  const queryTokens = tokenize(queryText);
204
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
+
205
353
  for (const token of queryTokens) {
206
354
  const docFreq = this.df.get(token);
207
355
  if (!docFreq) continue;
208
- const idf = Math.log(this.docCount / docFreq);
209
- for (const [chunkId, tfMap] of this.tf.entries()) {
210
- const tf = tfMap.get(token);
211
- 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);
212
365
  }
213
366
  }
214
367
  return Array.from(scores.entries())
@@ -216,6 +369,8 @@ export class MemoryGraphIndex {
216
369
  .map(([id, score], rank) => ({ id, score, rank: rank + 1 }));
217
370
  }
218
371
 
372
+ // ─── Vector search ─────────────────────────────────────────────────────────
373
+
219
374
  _invalidateMatrix() {
220
375
  this._matrixDirty = true;
221
376
  this._hnsw = null;
@@ -235,17 +390,14 @@ export class MemoryGraphIndex {
235
390
  const n = ids.length;
236
391
  this._vecIds = ids;
237
392
  if (n === 0) {
238
- this._vecMatrix = null;
239
- this._vecNorms = null;
240
- this._dim = 0;
241
- this._hnsw = null;
242
- this._matrixDirty = false;
393
+ this._vecMatrix = null; this._vecNorms = null;
394
+ this._dim = 0; this._hnsw = null; this._matrixDirty = false;
243
395
  return;
244
396
  }
245
397
  const dim = this.vectors.get(ids[0]).length;
246
398
  this._dim = dim;
247
399
  const matrix = new Float32Array(n * dim);
248
- const norms = new Float32Array(n);
400
+ const norms = new Float32Array(n);
249
401
  for (let i = 0; i < n; i++) {
250
402
  const v = this.vectors.get(ids[i]);
251
403
  let normSq = 0;
@@ -258,10 +410,10 @@ export class MemoryGraphIndex {
258
410
  norms[i] = Math.sqrt(normSq);
259
411
  }
260
412
  this._vecMatrix = matrix;
261
- this._vecNorms = norms;
413
+ this._vecNorms = norms;
262
414
  this._matrixDirty = false;
263
415
 
264
- // Build HNSW index for large corpora when library is available
416
+ // HNSW for large eager corpora
265
417
  if (HierarchicalNSW && n >= HNSW_THRESHOLD) {
266
418
  try {
267
419
  const hnsw = new HierarchicalNSW('cosine', dim);
@@ -275,28 +427,27 @@ export class MemoryGraphIndex {
275
427
  }
276
428
  this._hnsw = hnsw;
277
429
  } catch (e) {
278
- 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`);
279
431
  this._hnsw = null;
280
432
  }
281
433
  }
282
434
  }
283
435
 
436
+ /** Full flat-scan / HNSW search (eager mode). */
284
437
  _searchVector(queryVector, minScore = 0.3) {
285
438
  if (this.vectors.size === 0) return [];
286
439
  if (this._matrixDirty) this._rebuildMatrix();
287
440
  if (!this._vecMatrix) return [];
288
441
  if (queryVector.length !== this._dim) return [];
289
442
 
290
- const n = this._vecIds.length;
443
+ const n = this._vecIds.length;
291
444
  const dim = this._dim;
292
-
293
- // Pre-compute query L2 norm once
294
445
  let qNorm = 0;
295
446
  for (let d = 0; d < dim; d++) qNorm += queryVector[d] * queryVector[d];
296
447
  qNorm = Math.sqrt(qNorm);
297
448
  if (qNorm === 0) return [];
298
449
 
299
- // Large-corpus fast path: approximate nearest-neighbour via HNSW
450
+ // HNSW fast path
300
451
  if (this._hnsw) {
301
452
  const topK = Math.min(200, n);
302
453
  try {
@@ -304,16 +455,14 @@ export class MemoryGraphIndex {
304
455
  const { neighbors, distances } = this._hnsw.searchKnn(qArr, topK);
305
456
  const results = [];
306
457
  for (let i = 0; i < neighbors.length; i++) {
307
- const score = 1 - distances[i]; // cosine distance → similarity
458
+ const score = 1 - distances[i];
308
459
  if (score > minScore) results.push({ id: this._vecIds[neighbors[i]], score, rank: i + 1 });
309
460
  }
310
461
  return results;
311
- } catch {
312
- // HNSW search failed; fall through to exact flat scan
313
- }
462
+ } catch { /* fall through to flat scan */ }
314
463
  }
315
464
 
316
- // Exact brute-force flat scan — O(N·d), no marshalling overhead
465
+ // Exact flat scan
317
466
  const results = [];
318
467
  for (let i = 0; i < n; i++) {
319
468
  const base = i * dim;
@@ -327,25 +476,64 @@ export class MemoryGraphIndex {
327
476
  return results;
328
477
  }
329
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
+
330
512
  searchHybrid(queryText, queryVector, topK = 5, minScore = 0.3, exactBoostName = null) {
331
513
  const lexicalResults = this._searchLexical(queryText);
332
- const vectorResults = queryVector ? this._searchVector(queryVector, minScore) : [];
333
- const rrfScores = new Map();
334
- const K = this.rrfK;
335
514
 
336
- const queryLower = queryText.toLowerCase();
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
+ }
337
527
 
338
- // 🥇 FILENAME PATH BOOST: when Ollama offline (no vectors), boost chunks from
339
- // files whose path contains a token from the query (e.g. "auth middleware" → authStore.ts)
340
- const vectorEmpty = vectorResults.length === 0;
341
- const queryFileTokens = vectorEmpty
342
- ? queryLower.split(/[\s\W_]+/).filter(t => t.length >= 3)
343
- : [];
528
+ const rrfScores = new Map();
529
+ const K = this.rrfK;
530
+ const queryLower = queryText.toLowerCase();
344
531
 
345
- // Weighted RRF: lexical weight 1.5×, vector weight 1.0×
346
- // Keeps semantic lift without drowning precise TF-IDF matches.
347
532
  const LEXICAL_WEIGHT = 1.5;
348
- const VECTOR_WEIGHT = 1.0;
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);
349
537
 
350
538
  const allResults = [
351
539
  ...vectorResults.map(r => ({ ...r, _w: VECTOR_WEIGHT })),
@@ -357,43 +545,61 @@ export class MemoryGraphIndex {
357
545
  const chunk = this.chunks.get(id);
358
546
  if (!chunk) continue;
359
547
 
360
- // Generic: demote test/spec files when the query is not about tests.
361
- // Catches: foo.test.js, foo.spec.js, __tests__/foo.js, foo_test.go,
362
- // test/foo.js, tests/foo.js, spec/foo.js (plain test dirs).
548
+ // Demotion: test / spec files
363
549
  if (/\.(test|spec)\.|[/\\]__tests__[/\\]|_test\.|^tests?[/\\]|[/\\]tests?[/\\]|[/\\]spec[/\\]/.test(chunk.file_path)) {
364
550
  if (!queryLower.includes('test') && !queryLower.includes('spec')) baseScore *= 0.25;
365
551
  }
366
- // Demote example/sample/demo/docs directories — prefer canonical lib/src definitions
367
- if (/^examples?[/\\]|[/\\]examples?[/\\]|^samples?[/\\]|[/\\]samples?[/\\]|^demos?[/\\]|[/\\]demos?[/\\]|^docs?_?src[/\\]|[/\\]docs?_?src[/\\]|^docs?[/\\]|[/\\]tutorials?[/\\]/.test(chunk.file_path)) {
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)) {
368
555
  baseScore *= 0.5;
369
556
  }
370
- // Prefer definitions (functions/classes/impls) over pure usage/expression sites
557
+ // Demotion: pure expression sites
371
558
  if (chunk.node_type === 'expression_statement' || chunk.node_type === 'call_expression') {
372
559
  baseScore *= 0.8;
373
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
+ }
374
569
 
375
- // Filename path boost when no vectors available.
376
- // Also checks if any query token is a prefix of a path segment (≥4 chars),
377
- // so "initialise" matches "init.js", "decorator" matches "decorators/", etc.
378
- if (vectorEmpty && queryFileTokens.length > 0) {
379
- const filePathLower = chunk.file_path.toLowerCase();
380
- const hasExact = queryFileTokens.some(t => filePathLower.includes(t));
381
- const hasPrefix = !hasExact && queryFileTokens.some(t =>
382
- t.length >= 4 && filePathLower.split(/[/\\._-]/).some(seg => seg.startsWith(t.slice(0, 5)))
383
- );
384
- if (hasExact) baseScore *= 1.5;
385
- else if (hasPrefix) baseScore *= 1.25;
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
+ }
386
584
  }
387
585
 
388
- // Name-exact-token boost (applies in both lexical and hybrid mode):
389
- // If the chunk name exactly matches any significant query token (≥4 chars),
390
- // give it an extra 1.4× lift. This promotes WriteJSON over WriteContentType
391
- // when the query contains "JSON", and GET/POST/Handle over GetPostForm.
392
- if (chunk.name) {
393
- const nameLower = chunk.name.toLowerCase();
394
- const queryTokensAll = queryLower.split(/[\s\W_]+/).filter(t => t.length >= 4);
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);
395
595
  if (queryTokensAll.some(t => nameLower === t || nameLower.endsWith('.' + t))) {
396
- baseScore *= 1.4;
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
+ }
397
603
  }
398
604
  }
399
605
 
@@ -416,30 +622,19 @@ export class MemoryGraphIndex {
416
622
  .filter(r => r.chunk !== undefined);
417
623
  }
418
624
 
419
- _loadEmbeddingBinary(buf) {
420
- let off = 0;
421
- const count = buf.readUInt32LE(off); off += 4;
422
- for (let i = 0; i < count; i++) {
423
- const hashLen = buf.readUInt32LE(off); off += 4;
424
- const hash = buf.subarray(off, off + hashLen).toString('utf8'); off += hashLen;
425
- const dim = buf.readUInt32LE(off); off += 4;
426
- const vec = new Float32Array(dim);
427
- for (let d = 0; d < dim; d++) { vec[d] = buf.readFloatLE(off); off += 4; }
428
- this.embeddingCache.set(hash, vec);
429
- }
430
- }
625
+ // ─── Persistence ───────────────────────────────────────────────────────────
431
626
 
432
627
  async save() {
433
628
  const chunksData = Array.from(this.chunks.values()).map(c => ({
434
629
  id: c.id, file_path: c.file_path, node_type: c.node_type,
435
630
  name: c.name, docstring: c.docstring || '', code_snippet: c.code_snippet,
436
631
  content_hash: c.content_hash, start_line: c.start_line, end_line: c.end_line,
437
- calls: c.calls || [],
438
- params: c.params || [], return_type: c.return_type || '', class_context: c.class_context || ''
632
+ calls: c.calls || [], params: c.params || [],
633
+ return_type: c.return_type || '', class_context: c.class_context || '',
634
+ type_refs: c.type_refs || [],
439
635
  }));
440
- // Embeddings go to a compact binary sidecar — NOT in the JSON payload
441
- const payload = JSON.stringify({ chunks: chunksData, graph: this.graph });
442
- const tmpPath = `${this.indexPath}.tmp`;
636
+ const payload = JSON.stringify({ chunks: chunksData, graph: this.graph });
637
+ const tmpPath = `${this.indexPath}.tmp`;
443
638
  const tmpBinPath = `${this._embeddingPath}.tmp`;
444
639
  await Promise.all([
445
640
  fs.promises.writeFile(tmpPath, payload),
@@ -473,4 +668,13 @@ export class MemoryGraphIndex {
473
668
  if (!this.graph.importedBy[dep].includes(filePath)) this.graph.importedBy[dep].push(filePath);
474
669
  }
475
670
  }
476
- }
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
+ }