graph-indexer 1.0.1 → 1.0.3
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/PROMPT.md +143 -35
- package/README.md +227 -862
- package/core-engine.mjs +363 -159
- package/indexer.mjs +8 -8
- package/init.mjs +179 -68
- package/mcp-server.mjs +397 -150
- package/package.json +13 -13
- package/parser-utils.mjs +94 -23
- package/watch-daemon.mjs +2 -2
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
|
-
//
|
|
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,54 +78,110 @@ 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]);
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
173
|
-
|
|
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
|
|
176
|
-
|
|
177
|
-
|
|
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
|
|
191
|
-
if (!
|
|
192
|
-
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
|
+
}
|
|
193
325
|
const freq = this.df.get(term);
|
|
194
|
-
if (freq
|
|
195
|
-
|
|
196
|
-
|
|
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.
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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.
|
|
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
|
|
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
|
|
413
|
+
this._vecNorms = norms;
|
|
262
414
|
this._matrixDirty = false;
|
|
263
415
|
|
|
264
|
-
//
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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];
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
339
|
-
|
|
340
|
-
const
|
|
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
367
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
376
|
-
//
|
|
377
|
-
//
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
const
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
|
389
|
-
//
|
|
390
|
-
//
|
|
391
|
-
//
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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 *=
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
441
|
-
const
|
|
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
|
+
}
|