graph-indexer 1.0.3 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/PROMPT.md +182 -143
- package/README.md +347 -74
- package/config.mjs +118 -0
- package/core-engine.mjs +631 -188
- package/enrichment.mjs +347 -0
- package/indexer.mjs +100 -88
- package/init.mjs +4 -2
- package/mcp-server.mjs +87 -564
- package/mcp-tools.mjs +545 -0
- package/package.json +17 -7
- package/parser-utils.mjs +351 -10
- package/search-core.mjs +416 -0
- package/sqlite-store.mjs +768 -0
- package/storage.mjs +43 -0
- package/watch-daemon.mjs +172 -79
package/core-engine.mjs
CHANGED
|
@@ -6,13 +6,19 @@
|
|
|
6
6
|
* @license MIT
|
|
7
7
|
*/
|
|
8
8
|
import fs from 'fs';
|
|
9
|
+
import {
|
|
10
|
+
EMBEDDING_CONTEXT_LIMIT, truncateForEmbedding, cosineSimilarity, tokenize,
|
|
11
|
+
okapiIdf, bm25Score, fuseAndRank, buildLexicalDocument, embeddingKeyFor,
|
|
12
|
+
SUMMARY_VEC_SUFFIX, LEXICAL_FUSION_CAP, VECTOR_SCAN_RAW_N, finalizeVectorCandidates,
|
|
13
|
+
} from './search-core.mjs';
|
|
9
14
|
|
|
10
|
-
|
|
15
|
+
// Re-exported for backward compatibility — callers historically imported these
|
|
16
|
+
// retrieval primitives from core-engine; they now live in search-core.mjs.
|
|
17
|
+
export { EMBEDDING_CONTEXT_LIMIT, truncateForEmbedding, cosineSimilarity };
|
|
11
18
|
|
|
12
19
|
// Thresholds for adaptive vector loading strategy
|
|
13
20
|
const HNSW_THRESHOLD = 5000; // Build HNSW index above this (eager mode only)
|
|
14
21
|
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
22
|
|
|
17
23
|
// Optional HNSW accelerator for medium corpora (eager mode only)
|
|
18
24
|
let HierarchicalNSW = null;
|
|
@@ -21,37 +27,6 @@ try {
|
|
|
21
27
|
HierarchicalNSW = mod.HierarchicalNSW ?? mod.default?.HierarchicalNSW ?? null;
|
|
22
28
|
} catch { /* not installed — flat scan used */ }
|
|
23
29
|
|
|
24
|
-
export function truncateForEmbedding(text) {
|
|
25
|
-
return text.length > EMBEDDING_CONTEXT_LIMIT ? text.slice(0, EMBEDDING_CONTEXT_LIMIT) : text;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export function cosineSimilarity(vecA, vecB) {
|
|
29
|
-
let dot = 0, nA = 0, nB = 0;
|
|
30
|
-
for (let i = 0; i < vecA.length; i++) {
|
|
31
|
-
dot += vecA[i] * vecB[i];
|
|
32
|
-
nA += vecA[i] * vecA[i];
|
|
33
|
-
nB += vecB[i] * vecB[i];
|
|
34
|
-
}
|
|
35
|
-
return nA === 0 || nB === 0 ? 0 : dot / (Math.sqrt(nA) * Math.sqrt(nB));
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function tokenize(text) {
|
|
39
|
-
if (!text) return [];
|
|
40
|
-
const rawTokens = text.split(/[\s\W_]+/);
|
|
41
|
-
const tokens = [];
|
|
42
|
-
for (const word of rawTokens) {
|
|
43
|
-
if (word.length < 2) continue;
|
|
44
|
-
tokens.push(word.toLowerCase());
|
|
45
|
-
const camelParts = word.replace(/([a-z])([A-Z])/g, '$1 $2').split(' ');
|
|
46
|
-
if (camelParts.length > 1) {
|
|
47
|
-
for (const part of camelParts) {
|
|
48
|
-
if (part.length >= 2) tokens.push(part.toLowerCase());
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
return tokens;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
30
|
/**
|
|
56
31
|
* Serializes embeddingCache to compact binary.
|
|
57
32
|
* Format per entry: [uint32 hashLen][utf8 hash][uint32 dim][float32 × dim]
|
|
@@ -77,6 +52,361 @@ export function writeEmbeddingBinary(embeddingCache) {
|
|
|
77
52
|
return buf;
|
|
78
53
|
}
|
|
79
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Decode an `.embeddings.bin` file into a Map<content_hash, Float32Array>.
|
|
57
|
+
* Standalone (mirrors the instance loader) so the indexer can reuse cached
|
|
58
|
+
* vectors across runs regardless of the active storage backend.
|
|
59
|
+
*/
|
|
60
|
+
export function readEmbeddingBinary(filePath) {
|
|
61
|
+
const cache = new Map();
|
|
62
|
+
if (!fs.existsSync(filePath)) return cache;
|
|
63
|
+
const buf = fs.readFileSync(filePath);
|
|
64
|
+
let off = 0;
|
|
65
|
+
const count = buf.readUInt32LE(off); off += 4;
|
|
66
|
+
for (let i = 0; i < count; i++) {
|
|
67
|
+
const hashLen = buf.readUInt32LE(off); off += 4;
|
|
68
|
+
const hash = buf.subarray(off, off + hashLen).toString('utf8'); off += hashLen;
|
|
69
|
+
const dim = buf.readUInt32LE(off); off += 4;
|
|
70
|
+
const vec = new Float32Array(dim);
|
|
71
|
+
for (let d = 0; d < dim; d++) { vec[d] = buf.readFloatLE(off); off += 4; }
|
|
72
|
+
cache.set(hash, vec);
|
|
73
|
+
}
|
|
74
|
+
return cache;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Append embedding entries to an `.embeddings.bin` file in place (creating it if
|
|
79
|
+
* absent) and return the absolute float-data offset of each appended entry.
|
|
80
|
+
* The leading uint32 entry count is updated so readEmbeddingBinary stays valid.
|
|
81
|
+
*
|
|
82
|
+
* Used by the watch daemon for incremental updates: a full bin rewrite on every
|
|
83
|
+
* file change would be O(corpus) — appending is O(changed chunks). Dead entries
|
|
84
|
+
* from replaced chunks are compacted away on the next full index run.
|
|
85
|
+
*
|
|
86
|
+
* @param {string} filePath
|
|
87
|
+
* @param {Map<string, Float32Array|number[]>|object} entries key → vector
|
|
88
|
+
* @returns {Map<string, {offset:number, dim:number}>}
|
|
89
|
+
*/
|
|
90
|
+
export function appendEmbeddingBinary(filePath, entries) {
|
|
91
|
+
const list = entries instanceof Map ? Array.from(entries.entries()) : Object.entries(entries || {});
|
|
92
|
+
const offsets = new Map();
|
|
93
|
+
if (list.length === 0) return offsets;
|
|
94
|
+
|
|
95
|
+
if (!fs.existsSync(filePath)) {
|
|
96
|
+
const hdr = Buffer.alloc(4);
|
|
97
|
+
hdr.writeUInt32LE(0, 0);
|
|
98
|
+
fs.writeFileSync(filePath, hdr);
|
|
99
|
+
}
|
|
100
|
+
const fd = fs.openSync(filePath, 'r+');
|
|
101
|
+
try {
|
|
102
|
+
const pos = fs.fstatSync(fd).size;
|
|
103
|
+
let size = 0;
|
|
104
|
+
for (const [key, vec] of list) size += 4 + Buffer.byteLength(key, 'utf8') + 4 + vec.length * 4;
|
|
105
|
+
const buf = Buffer.allocUnsafe(size);
|
|
106
|
+
let off = 0;
|
|
107
|
+
for (const [key, vec] of list) {
|
|
108
|
+
const kb = Buffer.from(key, 'utf8');
|
|
109
|
+
buf.writeUInt32LE(kb.length, off); off += 4;
|
|
110
|
+
kb.copy(buf, off); off += kb.length;
|
|
111
|
+
buf.writeUInt32LE(vec.length, off); off += 4;
|
|
112
|
+
offsets.set(key, { offset: pos + off, dim: vec.length });
|
|
113
|
+
for (let d = 0; d < vec.length; d++) { buf.writeFloatLE(vec[d], off); off += 4; }
|
|
114
|
+
}
|
|
115
|
+
fs.writeSync(fd, buf, 0, buf.length, pos);
|
|
116
|
+
const hdr = Buffer.allocUnsafe(4);
|
|
117
|
+
fs.readSync(fd, hdr, 0, 4, 0);
|
|
118
|
+
hdr.writeUInt32LE(hdr.readUInt32LE(0) + list.length, 0);
|
|
119
|
+
fs.writeSync(fd, hdr, 0, 4, 0);
|
|
120
|
+
} finally {
|
|
121
|
+
fs.closeSync(fd);
|
|
122
|
+
}
|
|
123
|
+
return offsets;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Cosine-score EVERY vector in an `.embeddings.bin` against a query vector,
|
|
128
|
+
* streaming from disk in bounded buffers, and return the top-N entry keys.
|
|
129
|
+
*
|
|
130
|
+
* This is the semantic channel for the disk-backed stores. The previous design
|
|
131
|
+
* scored vectors only for chunks that already matched lexically, which silently
|
|
132
|
+
* disabled semantic search exactly where it matters: conceptual queries that
|
|
133
|
+
* share no tokens with the code. A sequential scan of the bin is fast (the OS
|
|
134
|
+
* page cache holds it after the first query) and keeps resident RAM flat.
|
|
135
|
+
*
|
|
136
|
+
* @param {{fd?: number, buffer?: Buffer}} source Open fd or in-memory buffer of the bin.
|
|
137
|
+
* @param {Float32Array|number[]} queryVector
|
|
138
|
+
* @param {{topN?: number, minScore?: number}} [opts]
|
|
139
|
+
* @returns {Array<{key: string, score: number}>} Sorted by score, descending.
|
|
140
|
+
*/
|
|
141
|
+
export function scanEmbeddingBinary({ fd = -1, buffer = null }, queryVector, { topN = 200, minScore = 0 } = {}) {
|
|
142
|
+
const qDim = queryVector.length;
|
|
143
|
+
let qNorm = 0;
|
|
144
|
+
for (let d = 0; d < qDim; d++) qNorm += queryVector[d] * queryVector[d];
|
|
145
|
+
qNorm = Math.sqrt(qNorm);
|
|
146
|
+
if (qNorm === 0) return [];
|
|
147
|
+
|
|
148
|
+
const top = []; // sorted desc, capped at topN
|
|
149
|
+
let worst = -Infinity;
|
|
150
|
+
const push = (key, score) => {
|
|
151
|
+
if (score <= minScore) return;
|
|
152
|
+
if (top.length >= topN && score <= worst) return;
|
|
153
|
+
let lo = 0, hi = top.length;
|
|
154
|
+
while (lo < hi) { const mid = (lo + hi) >> 1; if (top[mid].score >= score) lo = mid + 1; else hi = mid; }
|
|
155
|
+
top.splice(lo, 0, { key, score });
|
|
156
|
+
if (top.length > topN) top.pop();
|
|
157
|
+
worst = top[top.length - 1].score;
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
// Parses complete entries in buf starting at startOff; returns the offset of
|
|
161
|
+
// the first incomplete entry (callers carry the remainder into the next read).
|
|
162
|
+
const processEntries = (buf, startOff) => {
|
|
163
|
+
let off = startOff;
|
|
164
|
+
for (;;) {
|
|
165
|
+
if (off + 4 > buf.length) return off;
|
|
166
|
+
const keyLen = buf.readUInt32LE(off);
|
|
167
|
+
if (off + 4 + keyLen + 4 > buf.length) return off;
|
|
168
|
+
const dim = buf.readUInt32LE(off + 4 + keyLen);
|
|
169
|
+
const entryEnd = off + 4 + keyLen + 4 + dim * 4;
|
|
170
|
+
if (entryEnd > buf.length) return off;
|
|
171
|
+
if (dim === qDim) {
|
|
172
|
+
const key = buf.toString('utf8', off + 4, off + 4 + keyLen);
|
|
173
|
+
let dp = 0, nv = 0;
|
|
174
|
+
let p = off + 4 + keyLen + 4;
|
|
175
|
+
for (let d = 0; d < dim; d++, p += 4) {
|
|
176
|
+
const v = buf.readFloatLE(p);
|
|
177
|
+
dp += queryVector[d] * v;
|
|
178
|
+
nv += v * v;
|
|
179
|
+
}
|
|
180
|
+
const n = Math.sqrt(nv);
|
|
181
|
+
if (n > 0) push(key, dp / (qNorm * n));
|
|
182
|
+
}
|
|
183
|
+
off = entryEnd;
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
if (buffer) { processEntries(buffer, 4); return top; }
|
|
188
|
+
if (fd < 0) return [];
|
|
189
|
+
|
|
190
|
+
const CHUNK = 4 * 1024 * 1024;
|
|
191
|
+
let filePos = 0;
|
|
192
|
+
let carry = null;
|
|
193
|
+
let first = true;
|
|
194
|
+
for (;;) {
|
|
195
|
+
const buf = Buffer.allocUnsafe(CHUNK);
|
|
196
|
+
const read = fs.readSync(fd, buf, 0, CHUNK, filePos);
|
|
197
|
+
if (read <= 0) break;
|
|
198
|
+
filePos += read;
|
|
199
|
+
const work = carry && carry.length
|
|
200
|
+
? Buffer.concat([carry, buf.subarray(0, read)])
|
|
201
|
+
: buf.subarray(0, read);
|
|
202
|
+
const consumed = processEntries(work, first ? 4 : 0);
|
|
203
|
+
first = false;
|
|
204
|
+
carry = work.subarray(consumed);
|
|
205
|
+
if (read < CHUNK) break;
|
|
206
|
+
}
|
|
207
|
+
return top;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ─── Binary-quantized vector sketch ─────────────────────────────────────────────
|
|
211
|
+
// The streaming full scan above is exact but O(corpus × dim) per query: ~104 ms
|
|
212
|
+
// at 50k chunks and ~519 ms at 200k (measured warm). The sketch removes that
|
|
213
|
+
// ceiling while keeping resident RAM bounded: each vector is reduced to its SIGN
|
|
214
|
+
// BITS (768 dims → 96 bytes, 0.1% of the float32 data). A query is answered by
|
|
215
|
+
// (1) a Hamming-distance pass over the packed bits (XOR + popcount over Uint32
|
|
216
|
+
// words — a few ms even at 200k rows), keeping the best `oversample × topN`
|
|
217
|
+
// candidates, then (2) an exact cosine rescore of only those candidates, pread
|
|
218
|
+
// from the bin. Sign quantization of normalized embeddings preserves cosine
|
|
219
|
+
// ordering well enough that a 4× oversampled rescore recovers the exact top-N
|
|
220
|
+
// (validated in test/unit.mjs against the exhaustive scan).
|
|
221
|
+
//
|
|
222
|
+
// The sketch is APPEND-AWARE: the bin only ever grows between full rebuilds
|
|
223
|
+
// (the daemon appends), so `updateVectorSketch` re-reads just the tail beyond
|
|
224
|
+
// what it has already consumed, keeping refresh O(changed chunks).
|
|
225
|
+
|
|
226
|
+
function popcnt32(x) {
|
|
227
|
+
x -= (x >> 1) & 0x55555555;
|
|
228
|
+
x = (x & 0x33333333) + ((x >> 2) & 0x33333333);
|
|
229
|
+
x = (x + (x >> 4)) & 0x0f0f0f0f;
|
|
230
|
+
return (x * 0x01010101) >> 24;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Pack a float vector's sign bits into `words` Uint32 words. */
|
|
234
|
+
function quantizeToBits(vec, out, base, words) {
|
|
235
|
+
for (let w = 0; w < words; w++) {
|
|
236
|
+
let bits = 0;
|
|
237
|
+
const d0 = w * 32;
|
|
238
|
+
const dMax = Math.min(d0 + 32, vec.length);
|
|
239
|
+
for (let d = d0; d < dMax; d++) {
|
|
240
|
+
if (vec[d] > 0) bits |= (1 << (d - d0));
|
|
241
|
+
}
|
|
242
|
+
out[base + w] = bits;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Build or incrementally extend a binary sketch of an `.embeddings.bin`.
|
|
248
|
+
*
|
|
249
|
+
* @param {object|null} sketch Existing sketch to extend, or null to create.
|
|
250
|
+
* @param {{fd?: number, buffer?: Buffer}} source
|
|
251
|
+
* @returns {object} sketch { dim, words, n, keys[], offsets[], bits: Uint32Array,
|
|
252
|
+
* consumed, headerCount, firstKey }
|
|
253
|
+
*/
|
|
254
|
+
export function updateVectorSketch(sketch, { fd = -1, buffer = null }) {
|
|
255
|
+
const readAt = (buf, pos, len) => {
|
|
256
|
+
if (buffer) return buffer.subarray(pos, pos + len);
|
|
257
|
+
const b = Buffer.allocUnsafe(len);
|
|
258
|
+
const r = fs.readSync(fd, b, 0, len, pos);
|
|
259
|
+
return r < len ? b.subarray(0, r) : b;
|
|
260
|
+
};
|
|
261
|
+
const fileSize = buffer ? buffer.length : (fd >= 0 ? fs.fstatSync(fd).size : 0);
|
|
262
|
+
if (fileSize < 4) return sketch;
|
|
263
|
+
|
|
264
|
+
const headerCount = readAt(null, 0, 4).readUInt32LE(0);
|
|
265
|
+
if (!sketch) {
|
|
266
|
+
sketch = {
|
|
267
|
+
dim: 0, words: 0, n: 0, keys: [], offsets: [],
|
|
268
|
+
bits: new Uint32Array(0), consumed: 4, headerCount: 0, firstKey: null,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
if (fileSize <= sketch.consumed) return sketch;
|
|
272
|
+
|
|
273
|
+
// Read the unseen tail in large blocks. No carry buffers / Buffer.concat —
|
|
274
|
+
// the next block simply re-reads from the last complete entry boundary
|
|
275
|
+
// (at most one partial entry of overlap), so the build leaves no transient
|
|
276
|
+
// garbage behind. Sign bits are quantized straight off the block buffer.
|
|
277
|
+
const CHUNK = 8 * 1024 * 1024;
|
|
278
|
+
const reusable = buffer ? null : Buffer.allocUnsafe(CHUNK);
|
|
279
|
+
let pos = sketch.consumed;
|
|
280
|
+
while (pos < fileSize) {
|
|
281
|
+
const len = Math.min(CHUNK, fileSize - pos);
|
|
282
|
+
let work;
|
|
283
|
+
if (buffer) {
|
|
284
|
+
work = buffer.subarray(pos, pos + len);
|
|
285
|
+
} else {
|
|
286
|
+
const r = fs.readSync(fd, reusable, 0, len, pos);
|
|
287
|
+
work = r < len ? reusable.subarray(0, r) : (len < CHUNK ? reusable.subarray(0, len) : reusable);
|
|
288
|
+
}
|
|
289
|
+
let off = 0;
|
|
290
|
+
let parsedAny = false;
|
|
291
|
+
for (;;) {
|
|
292
|
+
if (off + 4 > work.length) break;
|
|
293
|
+
const keyLen = work.readUInt32LE(off);
|
|
294
|
+
if (off + 4 + keyLen + 4 > work.length) break;
|
|
295
|
+
const dim = work.readUInt32LE(off + 4 + keyLen);
|
|
296
|
+
const entryEnd = off + 4 + keyLen + 4 + dim * 4;
|
|
297
|
+
if (entryEnd > work.length) break;
|
|
298
|
+
|
|
299
|
+
const key = work.toString('utf8', off + 4, off + 4 + keyLen);
|
|
300
|
+
const floatBase = off + 4 + keyLen + 4;
|
|
301
|
+
if (sketch.dim === 0) {
|
|
302
|
+
sketch.dim = dim;
|
|
303
|
+
sketch.words = Math.ceil(dim / 32);
|
|
304
|
+
}
|
|
305
|
+
if (dim === sketch.dim) {
|
|
306
|
+
if ((sketch.n + 1) * sketch.words > sketch.bits.length) {
|
|
307
|
+
const grown = new Uint32Array(Math.max(1024 * sketch.words, sketch.bits.length * 2));
|
|
308
|
+
grown.set(sketch.bits);
|
|
309
|
+
sketch.bits = grown;
|
|
310
|
+
}
|
|
311
|
+
// Quantize sign bits directly from the block buffer.
|
|
312
|
+
const bitBase = sketch.n * sketch.words;
|
|
313
|
+
for (let w = 0; w < sketch.words; w++) {
|
|
314
|
+
let bits = 0;
|
|
315
|
+
const d0 = w * 32;
|
|
316
|
+
const dMax = Math.min(d0 + 32, dim);
|
|
317
|
+
for (let d = d0; d < dMax; d++) {
|
|
318
|
+
if (work.readFloatLE(floatBase + d * 4) > 0) bits |= (1 << (d - d0));
|
|
319
|
+
}
|
|
320
|
+
sketch.bits[bitBase + w] = bits;
|
|
321
|
+
}
|
|
322
|
+
sketch.keys.push(key);
|
|
323
|
+
sketch.offsets.push(pos + floatBase);
|
|
324
|
+
if (sketch.firstKey === null) sketch.firstKey = key;
|
|
325
|
+
sketch.n++;
|
|
326
|
+
}
|
|
327
|
+
off = entryEnd;
|
|
328
|
+
parsedAny = true;
|
|
329
|
+
}
|
|
330
|
+
if (!parsedAny) break; // partial entry larger than the block — corrupt tail
|
|
331
|
+
pos += off; // resume exactly at the last complete entry boundary
|
|
332
|
+
sketch.consumed = pos;
|
|
333
|
+
}
|
|
334
|
+
sketch.headerCount = headerCount;
|
|
335
|
+
return sketch;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Approximate top-N search via the sketch + exact cosine rescore from the bin.
|
|
340
|
+
* Drop-in replacement for scanEmbeddingBinary (same return shape), ~20–50×
|
|
341
|
+
* faster at scale.
|
|
342
|
+
*
|
|
343
|
+
* @returns {Array<{key: string, score: number}>} sorted by exact cosine, desc.
|
|
344
|
+
*/
|
|
345
|
+
export function searchVectorSketch(sketch, { fd = -1, buffer = null }, queryVector, {
|
|
346
|
+
topN = 200, minScore = 0, oversample = 4,
|
|
347
|
+
} = {}) {
|
|
348
|
+
if (!sketch || sketch.n === 0 || queryVector.length !== sketch.dim) return [];
|
|
349
|
+
const words = sketch.words;
|
|
350
|
+
|
|
351
|
+
// 1. Quantize the query and rank rows by Hamming distance (bounded top-M).
|
|
352
|
+
const qbits = new Uint32Array(words);
|
|
353
|
+
quantizeToBits(queryVector, qbits, 0, words);
|
|
354
|
+
|
|
355
|
+
const M = Math.min(sketch.n, Math.max(topN * oversample, 64));
|
|
356
|
+
const candIdx = new Int32Array(M);
|
|
357
|
+
const candHam = new Int32Array(M);
|
|
358
|
+
let count = 0, worst = -1;
|
|
359
|
+
const bits = sketch.bits;
|
|
360
|
+
for (let i = 0; i < sketch.n; i++) {
|
|
361
|
+
const base = i * words;
|
|
362
|
+
let ham = 0;
|
|
363
|
+
for (let w = 0; w < words; w++) ham += popcnt32((bits[base + w] ^ qbits[w]) >>> 0);
|
|
364
|
+
if (count >= M && ham >= worst) continue;
|
|
365
|
+
// bounded insertion sort (ascending hamming)
|
|
366
|
+
let lo = 0, hi = count;
|
|
367
|
+
while (lo < hi) { const mid = (lo + hi) >> 1; if (candHam[mid] <= ham) lo = mid + 1; else hi = mid; }
|
|
368
|
+
const end = Math.min(count, M - 1);
|
|
369
|
+
for (let j = end; j > lo; j--) { candHam[j] = candHam[j - 1]; candIdx[j] = candIdx[j - 1]; }
|
|
370
|
+
candHam[lo] = ham; candIdx[lo] = i;
|
|
371
|
+
if (count < M) count++;
|
|
372
|
+
worst = candHam[count - 1];
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// 2. Exact cosine rescore of the candidates only (pread per candidate).
|
|
376
|
+
let qNorm = 0;
|
|
377
|
+
for (let d = 0; d < queryVector.length; d++) qNorm += queryVector[d] * queryVector[d];
|
|
378
|
+
qNorm = Math.sqrt(qNorm);
|
|
379
|
+
if (qNorm === 0) return [];
|
|
380
|
+
|
|
381
|
+
const byteLen = sketch.dim * 4;
|
|
382
|
+
const raw = Buffer.allocUnsafe(byteLen);
|
|
383
|
+
const scored = [];
|
|
384
|
+
for (let c = 0; c < count; c++) {
|
|
385
|
+
const i = candIdx[c];
|
|
386
|
+
let view;
|
|
387
|
+
if (buffer) {
|
|
388
|
+
view = buffer.subarray(sketch.offsets[i], sketch.offsets[i] + byteLen);
|
|
389
|
+
if (view.length < byteLen) continue;
|
|
390
|
+
} else {
|
|
391
|
+
const read = fs.readSync(fd, raw, 0, byteLen, sketch.offsets[i]);
|
|
392
|
+
if (read < byteLen) continue;
|
|
393
|
+
view = raw;
|
|
394
|
+
}
|
|
395
|
+
let dp = 0, nv = 0;
|
|
396
|
+
for (let d = 0; d < sketch.dim; d++) {
|
|
397
|
+
const v = view.readFloatLE(d * 4);
|
|
398
|
+
dp += queryVector[d] * v;
|
|
399
|
+
nv += v * v;
|
|
400
|
+
}
|
|
401
|
+
const n = Math.sqrt(nv);
|
|
402
|
+
if (n === 0) continue;
|
|
403
|
+
const score = dp / (qNorm * n);
|
|
404
|
+
if (score > minScore) scored.push({ key: sketch.keys[i], score });
|
|
405
|
+
}
|
|
406
|
+
scored.sort((a, b) => b.score - a.score);
|
|
407
|
+
return scored.slice(0, topN);
|
|
408
|
+
}
|
|
409
|
+
|
|
80
410
|
export class MemoryGraphIndex {
|
|
81
411
|
/**
|
|
82
412
|
* @param {string} indexPath
|
|
@@ -126,6 +456,8 @@ export class MemoryGraphIndex {
|
|
|
126
456
|
this._embeddingBuf = null; // Buffer kept for small-lazy corpora (<50k)
|
|
127
457
|
this._vecFd = -1; // open fd for disk-backed access (≥50k)
|
|
128
458
|
this._lazyMode = false;
|
|
459
|
+
this._keyToIds = null; // embedding key → chunkId[] (built lazily for scans)
|
|
460
|
+
this._sketch = null; // binary-quantized sketch (lazy mode, large corpora)
|
|
129
461
|
|
|
130
462
|
this.rrfK = rrfK;
|
|
131
463
|
this._saveTimer = null;
|
|
@@ -168,9 +500,18 @@ export class MemoryGraphIndex {
|
|
|
168
500
|
for (const chunk of (data.chunks || [])) {
|
|
169
501
|
this.chunks.set(chunk.id, chunk);
|
|
170
502
|
|
|
171
|
-
// Eager vector population
|
|
503
|
+
// Eager vector population. Vectors are keyed by embeddingKeyFor(chunk)
|
|
504
|
+
// (content_hash + enrichment digest); plain content_hash is the
|
|
505
|
+
// backward-compatible fallback for bins written before enrichment keys.
|
|
172
506
|
if (!this._lazyMode) {
|
|
173
|
-
|
|
507
|
+
const vecKey = embeddingKeyFor(chunk);
|
|
508
|
+
if (chunk.content_hash && this.embeddingCache.has(vecKey)) {
|
|
509
|
+
this.vectors.set(chunk.id, this.embeddingCache.get(vecKey));
|
|
510
|
+
// Summary-only second vector (enriched chunks): stored under a
|
|
511
|
+
// pseudo row id; searchHybrid folds hits back onto the chunk id.
|
|
512
|
+
const sVec = this.embeddingCache.get(vecKey + SUMMARY_VEC_SUFFIX);
|
|
513
|
+
if (sVec) this.vectors.set(chunk.id + SUMMARY_VEC_SUFFIX, sVec);
|
|
514
|
+
} else if (chunk.content_hash && this.embeddingCache.has(chunk.content_hash)) {
|
|
174
515
|
this.vectors.set(chunk.id, this.embeddingCache.get(chunk.content_hash));
|
|
175
516
|
} else if (chunk.embedding) {
|
|
176
517
|
const vec = new Float32Array(chunk.embedding);
|
|
@@ -179,21 +520,10 @@ export class MemoryGraphIndex {
|
|
|
179
520
|
}
|
|
180
521
|
}
|
|
181
522
|
|
|
182
|
-
// Build inverted lexical index
|
|
523
|
+
// Build inverted lexical index from the shared document builder
|
|
524
|
+
// (search-core.buildLexicalDocument) — identical text across backends.
|
|
183
525
|
const deps = this.graph.dependencies[chunk.file_path] || [];
|
|
184
|
-
|
|
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);
|
|
526
|
+
this._indexLexical(chunk.id, buildLexicalDocument(chunk, deps), chunk.file_path);
|
|
197
527
|
|
|
198
528
|
// Build symbol table (Frontier 2)
|
|
199
529
|
if (chunk.name && chunk.name !== 'anonymous') {
|
|
@@ -237,7 +567,8 @@ export class MemoryGraphIndex {
|
|
|
237
567
|
_getVecForChunk(chunkId) {
|
|
238
568
|
const chunk = this.chunks.get(chunkId);
|
|
239
569
|
if (!chunk?.content_hash) return null;
|
|
240
|
-
const entry = this._vecOffsets.get(chunk
|
|
570
|
+
const entry = this._vecOffsets.get(embeddingKeyFor(chunk))
|
|
571
|
+
?? this._vecOffsets.get(chunk.content_hash); // pre-enrichment-key bins
|
|
241
572
|
if (!entry) return null;
|
|
242
573
|
|
|
243
574
|
if (this._embeddingBuf) {
|
|
@@ -342,30 +673,22 @@ export class MemoryGraphIndex {
|
|
|
342
673
|
_searchLexical(queryText) {
|
|
343
674
|
const queryTokens = tokenize(queryText);
|
|
344
675
|
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
676
|
const avgdl = this.docCount > 0 ? this.totalDocLen / this.docCount : 1;
|
|
352
677
|
|
|
353
678
|
for (const token of queryTokens) {
|
|
354
679
|
const docFreq = this.df.get(token);
|
|
355
680
|
if (!docFreq) continue;
|
|
356
|
-
|
|
357
|
-
const idf = Math.log((this.docCount - docFreq + 0.5) / (docFreq + 0.5) + 1);
|
|
681
|
+
const idf = okapiIdf(this.docCount, docFreq);
|
|
358
682
|
const posting = this.invertedIndex.get(token);
|
|
359
683
|
if (!posting) continue;
|
|
360
684
|
for (const [chunkId, tf] of posting) {
|
|
361
685
|
const dl = this.docLens.get(chunkId) ?? avgdl;
|
|
362
|
-
|
|
363
|
-
const bm25 = idf * (tf * (k1 + 1)) / (tf + k1 * (1 - b + b * dl / avgdl));
|
|
364
|
-
scores.set(chunkId, (scores.get(chunkId) || 0) + bm25);
|
|
686
|
+
scores.set(chunkId, (scores.get(chunkId) || 0) + bm25Score(idf, tf, dl, avgdl));
|
|
365
687
|
}
|
|
366
688
|
}
|
|
367
689
|
return Array.from(scores.entries())
|
|
368
|
-
.sort((a, b) => b[1] - a[1])
|
|
690
|
+
.sort((a, b) => (b[1] - a[1]) || (a[0] < b[0] ? -1 : 1)) // id tie-break: backend parity
|
|
691
|
+
.slice(0, LEXICAL_FUSION_CAP) // same cap as SQLite: parity
|
|
369
692
|
.map(([id, score], rank) => ({ id, score, rank: rank + 1 }));
|
|
370
693
|
}
|
|
371
694
|
|
|
@@ -397,7 +720,10 @@ export class MemoryGraphIndex {
|
|
|
397
720
|
const dim = this.vectors.get(ids[0]).length;
|
|
398
721
|
this._dim = dim;
|
|
399
722
|
const matrix = new Float32Array(n * dim);
|
|
400
|
-
|
|
723
|
+
// Float64: norms must carry full precision — f32-rounded norms produced
|
|
724
|
+
// ~1e-7 score skew vs the disk-backed store's double-precision rescore,
|
|
725
|
+
// which was enough to flip near-tied ranks between backends.
|
|
726
|
+
const norms = new Float64Array(n);
|
|
401
727
|
for (let i = 0; i < n; i++) {
|
|
402
728
|
const v = this.vectors.get(ids[i]);
|
|
403
729
|
let normSq = 0;
|
|
@@ -477,34 +803,47 @@ export class MemoryGraphIndex {
|
|
|
477
803
|
}
|
|
478
804
|
|
|
479
805
|
/**
|
|
480
|
-
*
|
|
481
|
-
*
|
|
482
|
-
*
|
|
806
|
+
* Full vector search in lazy mode: stream the entire embeddings bin (bounded
|
|
807
|
+
* buffers, no full matrix in RAM) and map the top entry keys back to chunks.
|
|
808
|
+
* Replaces the old lexical-prefiltered scoring, which could never surface a
|
|
809
|
+
* chunk for a conceptual query that shared no tokens with the code.
|
|
483
810
|
*/
|
|
484
|
-
|
|
485
|
-
const
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
811
|
+
_scanVectorsLazy(queryVector, minScore = 0.3) {
|
|
812
|
+
const source = this._embeddingBuf ? { buffer: this._embeddingBuf } : { fd: this._vecFd };
|
|
813
|
+
if (!this._embeddingBuf && this._vecFd < 0) return [];
|
|
814
|
+
// Large corpora: binary sketch (built once on first vector query) replaces
|
|
815
|
+
// the exact O(corpus) scan with a Hamming prefilter + bounded rescore.
|
|
816
|
+
// 10k threshold matches the SQLite store so backends stay rank-identical
|
|
817
|
+
// wherever the exact scan is still fast.
|
|
818
|
+
if (!this._sketch && this._vecOffsets.size >= 10000) {
|
|
819
|
+
try { this._sketch = updateVectorSketch(null, source); } catch { this._sketch = null; }
|
|
820
|
+
}
|
|
821
|
+
const hits = this._sketch
|
|
822
|
+
? searchVectorSketch(this._sketch, source, queryVector, { topN: VECTOR_SCAN_RAW_N, minScore })
|
|
823
|
+
: scanEmbeddingBinary(source, queryVector, { topN: VECTOR_SCAN_RAW_N, minScore });
|
|
824
|
+
if (hits.length === 0) return [];
|
|
825
|
+
|
|
826
|
+
if (!this._keyToIds) {
|
|
827
|
+
const map = new Map();
|
|
828
|
+
const put = (key, id) => {
|
|
829
|
+
if (!map.has(key)) map.set(key, []);
|
|
830
|
+
map.get(key).push(id);
|
|
831
|
+
};
|
|
832
|
+
for (const c of this.chunks.values()) {
|
|
833
|
+
if (!c.content_hash) continue;
|
|
834
|
+
const key = embeddingKeyFor(c);
|
|
835
|
+
put(key, c.id);
|
|
836
|
+
put(key + SUMMARY_VEC_SUFFIX, c.id); // summary-only vector → same chunk
|
|
837
|
+
if (key !== c.content_hash) put(c.content_hash, c.id); // legacy bins
|
|
499
838
|
}
|
|
500
|
-
|
|
501
|
-
if (normV === 0) continue;
|
|
502
|
-
const score = dp / (qNorm * normV);
|
|
503
|
-
if (score > minScore) results.push({ id: chunkId, score });
|
|
839
|
+
this._keyToIds = map;
|
|
504
840
|
}
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
841
|
+
|
|
842
|
+
const entries = [];
|
|
843
|
+
for (const { key, score } of hits) {
|
|
844
|
+
for (const id of (this._keyToIds.get(key) || [])) entries.push({ id, score });
|
|
845
|
+
}
|
|
846
|
+
return finalizeVectorCandidates(entries);
|
|
508
847
|
}
|
|
509
848
|
|
|
510
849
|
// ─── Hybrid search ─────────────────────────────────────────────────────────
|
|
@@ -515,111 +854,42 @@ export class MemoryGraphIndex {
|
|
|
515
854
|
let vectorResults;
|
|
516
855
|
if (queryVector) {
|
|
517
856
|
if (this._lazyMode && this._vecOffsets.size > 0) {
|
|
518
|
-
//
|
|
519
|
-
|
|
520
|
-
vectorResults = this.
|
|
857
|
+
// Full streaming scan of the bin — covers conceptual queries with
|
|
858
|
+
// zero lexical overlap, which the old TF-IDF prefilter never could.
|
|
859
|
+
vectorResults = this._scanVectorsLazy(queryVector, minScore);
|
|
521
860
|
} else {
|
|
522
|
-
|
|
861
|
+
// Eager rows include summary-only pseudo ids (`<id>|s`) — fold each
|
|
862
|
+
// back onto its chunk via the shared finalizer (best score per
|
|
863
|
+
// chunk, deterministic order, same cap as the disk-backed paths).
|
|
864
|
+
const entries = this._searchVector(queryVector, minScore).map(r => ({
|
|
865
|
+
id: r.id.endsWith(SUMMARY_VEC_SUFFIX)
|
|
866
|
+
? r.id.slice(0, -SUMMARY_VEC_SUFFIX.length) : r.id,
|
|
867
|
+
score: r.score,
|
|
868
|
+
}));
|
|
869
|
+
vectorResults = finalizeVectorCandidates(entries);
|
|
523
870
|
}
|
|
524
871
|
} else {
|
|
525
872
|
vectorResults = [];
|
|
526
873
|
}
|
|
527
874
|
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
if (!chunk) continue;
|
|
547
|
-
|
|
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;
|
|
551
|
-
}
|
|
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
|
|
558
|
-
if (chunk.node_type === 'expression_statement' || chunk.node_type === 'call_expression') {
|
|
559
|
-
baseScore *= 0.8;
|
|
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
|
-
}
|
|
605
|
-
|
|
606
|
-
rrfScores.set(id, (rrfScores.get(id) || 0) + baseScore);
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
if (exactBoostName) {
|
|
610
|
-
const boostTerm = String(exactBoostName).toLowerCase().trim();
|
|
611
|
-
for (const [id, chunk] of this.chunks.entries()) {
|
|
612
|
-
if (chunk.name && String(chunk.name).toLowerCase() === boostTerm) {
|
|
613
|
-
rrfScores.set(id, (rrfScores.get(id) || 0) + 1 / (K + 1));
|
|
614
|
-
}
|
|
615
|
-
}
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
return Array.from(rrfScores.entries())
|
|
619
|
-
.sort((a, b) => b[1] - a[1])
|
|
620
|
-
.slice(0, topK)
|
|
621
|
-
.map(([id, rrfScore]) => ({ score: rrfScore, chunk: this.chunks.get(id) }))
|
|
622
|
-
.filter(r => r.chunk !== undefined);
|
|
875
|
+
// Fusion + boost ladder lives in search-core.mjs so the in-memory engine
|
|
876
|
+
// and the SQLite store rank identically. Backend state is reached through
|
|
877
|
+
// accessors; the math is measured once and shared.
|
|
878
|
+
return fuseAndRank({
|
|
879
|
+
lexicalResults,
|
|
880
|
+
vectorResults,
|
|
881
|
+
getChunk: (id) => this.chunks.get(id),
|
|
882
|
+
getPathTokens: (id) => this.pathTokens.get(id),
|
|
883
|
+
getDf: (t) => this.df.get(t) || 0,
|
|
884
|
+
docCount: this.docCount,
|
|
885
|
+
rrfK: this.rrfK,
|
|
886
|
+
topK,
|
|
887
|
+
queryText,
|
|
888
|
+
exactBoostName,
|
|
889
|
+
// Equivalent to the former full-scan (symbolTable is keyed by name.toLowerCase()),
|
|
890
|
+
// but O(1) and reusable by the SQLite backend.
|
|
891
|
+
resolveExact: (term) => this.symbolTable.get(term) || [],
|
|
892
|
+
});
|
|
623
893
|
}
|
|
624
894
|
|
|
625
895
|
// ─── Persistence ───────────────────────────────────────────────────────────
|
|
@@ -631,7 +901,9 @@ export class MemoryGraphIndex {
|
|
|
631
901
|
content_hash: c.content_hash, start_line: c.start_line, end_line: c.end_line,
|
|
632
902
|
calls: c.calls || [], params: c.params || [],
|
|
633
903
|
return_type: c.return_type || '', class_context: c.class_context || '',
|
|
634
|
-
type_refs: c.type_refs || [],
|
|
904
|
+
type_refs: c.type_refs || [], decorators: c.decorators || [],
|
|
905
|
+
extends: c.extends || [],
|
|
906
|
+
hyde: c.hyde || '', summary: c.summary || '', concepts: c.concepts || [],
|
|
635
907
|
}));
|
|
636
908
|
const payload = JSON.stringify({ chunks: chunksData, graph: this.graph });
|
|
637
909
|
const tmpPath = `${this.indexPath}.tmp`;
|
|
@@ -655,6 +927,98 @@ export class MemoryGraphIndex {
|
|
|
655
927
|
}, delayMs);
|
|
656
928
|
}
|
|
657
929
|
|
|
930
|
+
_removeSymbol(chunk) {
|
|
931
|
+
if (!chunk.name || chunk.name === 'anonymous') return;
|
|
932
|
+
const n = chunk.name.toLowerCase();
|
|
933
|
+
const set = this.symbolTable.get(n);
|
|
934
|
+
if (set) {
|
|
935
|
+
set.delete(chunk.id);
|
|
936
|
+
if (set.size === 0) this.symbolTable.delete(n);
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
/** Whether a vector for this embedding key is already cached (skip re-embedding). */
|
|
941
|
+
hasEmbedding(key) { return this.embeddingCache.has(key); }
|
|
942
|
+
|
|
943
|
+
/**
|
|
944
|
+
* Atomically replace every chunk of one file — the incremental-update entry
|
|
945
|
+
* point used by the watch daemon. Removes the file's old chunks from the
|
|
946
|
+
* lexical, vector AND symbol indexes (the old daemon path leaked symbol-table
|
|
947
|
+
* entries), stores newly computed embeddings, indexes the new chunks, and
|
|
948
|
+
* schedules a debounced save. Pass empty chunks/imports for a deleted file.
|
|
949
|
+
*
|
|
950
|
+
* @param {string} filePath
|
|
951
|
+
* @param {object} p
|
|
952
|
+
* @param {object[]} [p.chunks]
|
|
953
|
+
* @param {string[]} [p.imports]
|
|
954
|
+
* @param {Map<string, Float32Array|number[]>} [p.embeddings] New vectors keyed by embeddingKeyFor(chunk).
|
|
955
|
+
*/
|
|
956
|
+
applyFileUpdate(filePath, { chunks = [], imports = [], embeddings = null } = {}) {
|
|
957
|
+
this.updateFileGraph(filePath, imports);
|
|
958
|
+
|
|
959
|
+
for (const [id, chunk] of Array.from(this.chunks.entries())) {
|
|
960
|
+
if (chunk.file_path !== filePath) continue;
|
|
961
|
+
this._removeLexical(id);
|
|
962
|
+
this.removeVector(id);
|
|
963
|
+
this.removeVector(id + SUMMARY_VEC_SUFFIX);
|
|
964
|
+
this._removeSymbol(chunk);
|
|
965
|
+
this.chunks.delete(id);
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
if (embeddings) {
|
|
969
|
+
for (const [key, vec] of embeddings) {
|
|
970
|
+
this.embeddingCache.set(key, vec instanceof Float32Array ? vec : new Float32Array(vec));
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
for (const chunk of chunks) {
|
|
975
|
+
const vecKey = embeddingKeyFor(chunk);
|
|
976
|
+
const vec = this.embeddingCache.get(vecKey) ?? this.embeddingCache.get(chunk.content_hash);
|
|
977
|
+
if (vec) this.addVector(chunk.id, vec);
|
|
978
|
+
const sVec = this.embeddingCache.get(vecKey + SUMMARY_VEC_SUFFIX);
|
|
979
|
+
if (sVec) this.addVector(chunk.id + SUMMARY_VEC_SUFFIX, sVec);
|
|
980
|
+
this._indexLexical(chunk.id, buildLexicalDocument(chunk, imports), chunk.file_path);
|
|
981
|
+
this.chunks.set(chunk.id, chunk);
|
|
982
|
+
if (chunk.name && chunk.name !== 'anonymous') {
|
|
983
|
+
const n = chunk.name.toLowerCase();
|
|
984
|
+
if (!this.symbolTable.has(n)) this.symbolTable.set(n, new Set());
|
|
985
|
+
this.symbolTable.get(n).add(chunk.id);
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
this._keyToIds = null;
|
|
990
|
+
this.saveDebounced();
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
/**
|
|
994
|
+
* Drop every in-memory structure and re-read the index artifacts from disk.
|
|
995
|
+
* Used by the MCP server when the watch daemon (a separate process) rewrites
|
|
996
|
+
* code-index.json — without this the server would serve a stale snapshot
|
|
997
|
+
* until restart.
|
|
998
|
+
*/
|
|
999
|
+
reload() {
|
|
1000
|
+
if (this._vecFd >= 0) { try { fs.closeSync(this._vecFd); } catch {} this._vecFd = -1; }
|
|
1001
|
+
this.chunks = new Map();
|
|
1002
|
+
this.graph = { dependencies: {}, importedBy: {} };
|
|
1003
|
+
this.embeddingCache = new Map();
|
|
1004
|
+
this.symbolTable = new Map();
|
|
1005
|
+
this.invertedIndex = new Map();
|
|
1006
|
+
this.chunkTerms = new Map();
|
|
1007
|
+
this.docLens = new Map();
|
|
1008
|
+
this.totalDocLen = 0;
|
|
1009
|
+
this.pathTokens = new Map();
|
|
1010
|
+
this.docCount = 0;
|
|
1011
|
+
this.df = new Map();
|
|
1012
|
+
this.vectors = new Map();
|
|
1013
|
+
this._invalidateMatrix();
|
|
1014
|
+
this._vecOffsets = new Map();
|
|
1015
|
+
this._embeddingBuf = null;
|
|
1016
|
+
this._keyToIds = null;
|
|
1017
|
+
this._sketch = null;
|
|
1018
|
+
this._lazyMode = false;
|
|
1019
|
+
this.load();
|
|
1020
|
+
}
|
|
1021
|
+
|
|
658
1022
|
updateFileGraph(filePath, imports) {
|
|
659
1023
|
const oldDeps = this.graph.dependencies[filePath] || [];
|
|
660
1024
|
for (const oldDep of oldDeps) {
|
|
@@ -669,6 +1033,85 @@ export class MemoryGraphIndex {
|
|
|
669
1033
|
}
|
|
670
1034
|
}
|
|
671
1035
|
|
|
1036
|
+
// ─── Store contract ────────────────────────────────────────────────────────
|
|
1037
|
+
// The read surface the MCP tools consume. The SQLite store implements the same
|
|
1038
|
+
// methods over disk so tools never reach into a backend's internals.
|
|
1039
|
+
|
|
1040
|
+
get backend() { return 'memory'; }
|
|
1041
|
+
|
|
1042
|
+
/** @returns {object|null} */
|
|
1043
|
+
getChunk(id) { return this.chunks.get(id) ?? null; }
|
|
1044
|
+
|
|
1045
|
+
/** All chunks defined in a given file. @returns {object[]} */
|
|
1046
|
+
getChunksByFile(filePath) {
|
|
1047
|
+
const out = [];
|
|
1048
|
+
for (const c of this.chunks.values()) if (c.file_path === filePath) out.push(c);
|
|
1049
|
+
return out;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
/** Chunks whose symbol name matches exactly (case-insensitive). @returns {object[]} */
|
|
1053
|
+
resolveSymbol(name) {
|
|
1054
|
+
const ids = this.symbolTable.get(String(name).toLowerCase().trim());
|
|
1055
|
+
if (!ids) return [];
|
|
1056
|
+
const out = [];
|
|
1057
|
+
for (const id of ids) { const c = this.chunks.get(id); if (c) out.push(c); }
|
|
1058
|
+
return out;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
/** Chunks that call the given function name. @returns {object[]} */
|
|
1062
|
+
findCallers(funcName) {
|
|
1063
|
+
const out = [];
|
|
1064
|
+
for (const c of this.chunks.values()) if (c.calls?.includes(funcName)) out.push(c);
|
|
1065
|
+
return out;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
/** Lazily iterate every chunk (cursor-friendly parity with the SQLite store). */
|
|
1069
|
+
*iterateChunks() { yield* this.chunks.values(); }
|
|
1070
|
+
|
|
1071
|
+
chunkCount() { return this.chunks.size; }
|
|
1072
|
+
symbolCount() { return this.symbolTable.size; }
|
|
1073
|
+
fileCount() {
|
|
1074
|
+
const files = new Set();
|
|
1075
|
+
for (const c of this.chunks.values()) files.add(c.file_path);
|
|
1076
|
+
return files.size;
|
|
1077
|
+
}
|
|
1078
|
+
vectorCount() {
|
|
1079
|
+
if (this._lazyMode) {
|
|
1080
|
+
let n = 0;
|
|
1081
|
+
for (const k of this._vecOffsets.keys()) if (!k.endsWith(SUMMARY_VEC_SUFFIX)) n++;
|
|
1082
|
+
return n;
|
|
1083
|
+
}
|
|
1084
|
+
let n = 0;
|
|
1085
|
+
for (const k of this.vectors.keys()) if (!k.endsWith(SUMMARY_VEC_SUFFIX)) n++;
|
|
1086
|
+
return n;
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
getDependencies(filePath) { return this.graph.dependencies[filePath] || []; }
|
|
1090
|
+
getImportedBy(filePath) { return this.graph.importedBy[filePath] || []; }
|
|
1091
|
+
|
|
1092
|
+
/** Engine-level health facts for list_index_stats (daemon/age added by the tool). */
|
|
1093
|
+
stats() {
|
|
1094
|
+
const extCounts = new Map();
|
|
1095
|
+
for (const c of this.chunks.values()) {
|
|
1096
|
+
const ext = c.file_path.split('.').pop() || 'unknown';
|
|
1097
|
+
extCounts.set(ext, (extCounts.get(ext) || 0) + 1);
|
|
1098
|
+
}
|
|
1099
|
+
const vectorSource = this._lazyMode
|
|
1100
|
+
? (this._vecFd >= 0 ? 'disk-backed fd' : 'buffer-lazy')
|
|
1101
|
+
: 'eager (in-memory matrix)';
|
|
1102
|
+
return {
|
|
1103
|
+
backend: 'memory',
|
|
1104
|
+
chunks: this.chunks.size,
|
|
1105
|
+
files: this.fileCount(),
|
|
1106
|
+
symbols: this.symbolTable.size,
|
|
1107
|
+
vectors: this.vectorCount(),
|
|
1108
|
+
hasVectors: this.vectors.size > 0 || this._vecOffsets.size > 0,
|
|
1109
|
+
lazyMode: this._lazyMode,
|
|
1110
|
+
vectorSource,
|
|
1111
|
+
extCounts,
|
|
1112
|
+
};
|
|
1113
|
+
}
|
|
1114
|
+
|
|
672
1115
|
// ─── Cleanup ───────────────────────────────────────────────────────────────
|
|
673
1116
|
|
|
674
1117
|
close() {
|