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/LICENSE +21 -677
- package/PROMPT.md +160 -133
- package/README.md +244 -335
- package/core-engine.mjs +396 -121
- package/indexer.mjs +19 -19
- package/init.mjs +241 -0
- package/mcp-server.mjs +441 -117
- package/package.json +16 -4
- package/parser-utils.mjs +357 -17
- package/watch-daemon.mjs +25 -16
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
|
|
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
|
-
//
|
|
22
|
-
|
|
23
|
-
const
|
|
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
|
|
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
|
|
29
|
+
let dot = 0, nA = 0, nB = 0;
|
|
36
30
|
for (let i = 0; i < vecA.length; i++) {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
31
|
+
dot += vecA[i] * vecB[i];
|
|
32
|
+
nA += vecA[i] * vecA[i];
|
|
33
|
+
nB += vecB[i] * vecB[i];
|
|
40
34
|
}
|
|
41
|
-
return
|
|
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;
|
|
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
|
|
69
|
-
*
|
|
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;
|
|
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
|
-
|
|
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.
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
this.
|
|
101
|
-
this.
|
|
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
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
|
109
|
-
this._vecNorms
|
|
110
|
-
this._vecIds
|
|
111
|
-
this._dim
|
|
112
|
-
this._hnsw
|
|
113
|
-
|
|
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
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
if (chunk.
|
|
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
|
-
|
|
144
|
-
|
|
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
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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.
|
|
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
|
|
169
|
-
if (!
|
|
170
|
-
for (const term of
|
|
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
|
|
173
|
-
|
|
174
|
-
|
|
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.
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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.
|
|
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
|
|
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
|
|
413
|
+
this._vecNorms = norms;
|
|
240
414
|
this._matrixDirty = false;
|
|
241
415
|
|
|
242
|
-
//
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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];
|
|
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
|
|
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
|
-
|
|
317
|
-
|
|
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
|
-
//
|
|
322
|
-
if (/\.(test|spec)\.|[/\\]__tests__[/\\]|_test
|
|
323
|
-
if (!queryLower.includes('test') && !queryLower.includes('spec')) baseScore *= 0.
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
370
|
-
const
|
|
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
|
+
}
|