graph-indexer 1.2.0 → 2.0.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/CHANGELOG.md +412 -0
- package/README.md +216 -596
- package/SECURITY.md +68 -0
- package/TRADEMARK.md +31 -0
- package/cli-ui.mjs +78 -0
- package/config.mjs +179 -28
- package/daemon-ctl.mjs +175 -0
- package/daemon-lock.mjs +69 -0
- package/embedders/python/mlx_embed_server.py +61 -0
- package/embedders/python/requirements-mlx.txt +5 -0
- package/embedders/setup-mlx.mjs +110 -0
- package/embeddings.mjs +489 -0
- package/engine/binary.mjs +399 -0
- package/{core-engine.mjs → engine/memory.mjs} +107 -440
- package/{sqlite-store.mjs → engine/sqlite.mjs} +137 -42
- package/enrichment.mjs +111 -32
- package/git-signals.mjs +248 -0
- package/indexer.mjs +151 -42
- package/init.mjs +1603 -171
- package/layout.mjs +123 -0
- package/mcp/format.mjs +61 -0
- package/mcp/tools.mjs +1157 -0
- package/mcp/topology.mjs +551 -0
- package/mcp-server.mjs +55 -24
- package/package.json +44 -13
- package/parse/extractor.mjs +617 -0
- package/parse/imports.mjs +365 -0
- package/parse/languages.mjs +89 -0
- package/parse/metadata.mjs +655 -0
- package/parse/routes.mjs +327 -0
- package/prompts/CORE.md +86 -0
- package/prompts/DOMAIN_TEMPLATE.md +46 -0
- package/prompts/INTEGRATION.md +123 -0
- package/prompts/frameworks/ANDROID.md +17 -0
- package/prompts/frameworks/ASPNET_CORE.md +19 -0
- package/prompts/frameworks/FASTAPI_DJANGO.md +19 -0
- package/prompts/frameworks/LARAVEL_SYMFONY.md +22 -0
- package/prompts/frameworks/NODE_EXPRESS_NESTJS.md +18 -0
- package/prompts/frameworks/RAILS.md +20 -0
- package/prompts/frameworks/REACT.md +20 -0
- package/prompts/frameworks/SPRING_BOOT.md +19 -0
- package/prompts/languages/BASH.md +22 -0
- package/prompts/languages/C.md +21 -0
- package/prompts/languages/CSHARP.md +24 -0
- package/prompts/languages/CSS_SCSS.md +22 -0
- package/prompts/languages/GO.md +23 -0
- package/prompts/languages/JAVA.md +23 -0
- package/prompts/languages/JAVASCRIPT_TYPESCRIPT.md +24 -0
- package/prompts/languages/KOTLIN.md +24 -0
- package/prompts/languages/PHP.md +23 -0
- package/prompts/languages/PYTHON.md +23 -0
- package/prompts/languages/RUBY.md +23 -0
- package/prompts/languages/RUST.md +21 -0
- package/prompts/languages/SWIFT.md +21 -0
- package/search-core.mjs +254 -43
- package/storage.mjs +28 -3
- package/watch-daemon.mjs +69 -23
- package/PROMPT.md +0 -182
- package/mcp-tools.mjs +0 -545
- package/parser-utils.mjs +0 -1170
package/search-core.mjs
CHANGED
|
@@ -13,6 +13,10 @@
|
|
|
13
13
|
|
|
14
14
|
import { createHash } from 'crypto';
|
|
15
15
|
|
|
16
|
+
// Max embedding-input length in chars. ~8000 chars ≈ the usable context of the
|
|
17
|
+
// default embedders (nomic-embed-text / MiniLM) once a query/document prefix is
|
|
18
|
+
// added; longer inputs are silently truncated by the backend, so this is also the
|
|
19
|
+
// per-window size for oversized definitions (see embeddingWindows).
|
|
16
20
|
export const EMBEDDING_CONTEXT_LIMIT = 8000;
|
|
17
21
|
|
|
18
22
|
/**
|
|
@@ -43,31 +47,173 @@ export function truncateForEmbedding(text) {
|
|
|
43
47
|
return text.length > EMBEDDING_CONTEXT_LIMIT ? text.slice(0, EMBEDDING_CONTEXT_LIMIT) : text;
|
|
44
48
|
}
|
|
45
49
|
|
|
50
|
+
// Window-and-pool sub-chunking for the dense channel. A large definition (e.g. a
|
|
51
|
+
// ~600-line free function) used to be embedded as a single vector over only its
|
|
52
|
+
// first EMBEDDING_CONTEXT_LIMIT chars — its tail was invisible to semantic search
|
|
53
|
+
// (BM25 still indexed the whole text, so this gap is dense-only). Instead we embed
|
|
54
|
+
// a few overlapping windows and retrieve the chunk by the MAX cosine over its
|
|
55
|
+
// windows (max-sim), which preserves tail recall where mean-pooling would dilute it.
|
|
56
|
+
//
|
|
57
|
+
// 12% overlap: a definition that straddles a boundary still lands wholly inside an
|
|
58
|
+
// adjacent window (EMBEDDING_CONTEXT_LIMIT*0.12 ≈ 960 chars of shared context).
|
|
59
|
+
export const EMBEDDING_WINDOW_OVERLAP = 0.12;
|
|
60
|
+
// Hard cap on windows per chunk. Bounds embed cost and .bin growth at ~MAX× a
|
|
61
|
+
// single vector. 4 windows at the 7040-char stride cover ~29k chars (~700 lines);
|
|
62
|
+
// beyond that the god-class split already shards classes into method chunks, and a
|
|
63
|
+
// non-class definition that large is rare enough to accept head-only coverage.
|
|
64
|
+
export const EMBEDDING_MAX_WINDOWS = 4;
|
|
65
|
+
// Suffix for a chunk's window vectors past the first: <key>|w1, <key>|w2, … Window 0
|
|
66
|
+
// is the base key itself (identical to the single-vector path), so small chunks and
|
|
67
|
+
// the head of large chunks are byte-identical to before windowing existed.
|
|
68
|
+
export const WINDOW_VEC_SUFFIX = '|w';
|
|
69
|
+
|
|
70
|
+
const _VEC_SUFFIX_RE = /\|(?:s|w\d+)$/;
|
|
71
|
+
/** Strip a vector-key suffix (summary `|s` or window `|wN`) back to the base key, so
|
|
72
|
+
* every per-chunk vector folds onto one fusion candidate. Both backends use this. */
|
|
73
|
+
export function baseEmbeddingKey(key) {
|
|
74
|
+
return key.replace(_VEC_SUFFIX_RE, '');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Split an oversized embedding payload into overlapping windows of
|
|
79
|
+
* ≤EMBEDDING_CONTEXT_LIMIT chars, capped at EMBEDDING_MAX_WINDOWS. Returns [] when
|
|
80
|
+
* the payload fits in a single window (the default path — the caller embeds it under
|
|
81
|
+
* the base key unchanged). Window 0 is the same first-EMBEDDING_CONTEXT_LIMIT slice
|
|
82
|
+
* the single-vector path produced, so the base vector is unchanged; windows 1..N
|
|
83
|
+
* cover the tail and are stored under WINDOW_VEC_SUFFIX keys.
|
|
84
|
+
*
|
|
85
|
+
* @param {string} payload
|
|
86
|
+
* @returns {string[]} [] or [window0, window1, …] (length ≥ 2 when it splits).
|
|
87
|
+
*/
|
|
88
|
+
export function embeddingWindows(payload) {
|
|
89
|
+
if (!payload || payload.length <= EMBEDDING_CONTEXT_LIMIT) return [];
|
|
90
|
+
const stride = Math.max(1, Math.round(EMBEDDING_CONTEXT_LIMIT * (1 - EMBEDDING_WINDOW_OVERLAP)));
|
|
91
|
+
const windows = [];
|
|
92
|
+
for (let i = 0; i < EMBEDDING_MAX_WINDOWS; i++) {
|
|
93
|
+
const start = i * stride;
|
|
94
|
+
if (start >= payload.length) break;
|
|
95
|
+
windows.push(payload.slice(start, start + EMBEDDING_CONTEXT_LIMIT));
|
|
96
|
+
if (start + EMBEDDING_CONTEXT_LIMIT >= payload.length) break; // tail covered
|
|
97
|
+
}
|
|
98
|
+
return windows;
|
|
99
|
+
}
|
|
100
|
+
|
|
46
101
|
export function cosineSimilarity(vecA, vecB) {
|
|
47
102
|
let dot = 0, nA = 0, nB = 0;
|
|
48
103
|
for (let i = 0; i < vecA.length; i++) {
|
|
49
104
|
dot += vecA[i] * vecB[i];
|
|
50
|
-
nA
|
|
51
|
-
nB
|
|
105
|
+
nA += vecA[i] * vecA[i];
|
|
106
|
+
nB += vecB[i] * vecB[i];
|
|
52
107
|
}
|
|
53
108
|
return nA === 0 || nB === 0 ? 0 : dot / (Math.sqrt(nA) * Math.sqrt(nB));
|
|
54
109
|
}
|
|
55
110
|
|
|
111
|
+
// ─── Light Porter stemmer (additive recall bridge) ─────────────────────────────
|
|
112
|
+
// Collapses English morphological variants to a shared root so behavioural queries
|
|
113
|
+
// reach code identifiers across the inflection gap that lexical match otherwise
|
|
114
|
+
// misses: "intercepting"↔"Interceptor", "injection"↔"Injectable",
|
|
115
|
+
// "bootstrapping"↔"bootstrap", "managing"↔"Manager". Classic Porter (1980) steps
|
|
116
|
+
// 1–5 plus an agent-noun "-or" rule for code (Interceptor→intercept,
|
|
117
|
+
// Constructor→construct). Used ADDITIVELY in tokenize(): the raw token is always
|
|
118
|
+
// emitted and the stem only when it differs — so exact matches, df statistics and
|
|
119
|
+
// the name/path boosts are byte-for-byte unchanged; only recall is added.
|
|
120
|
+
// Deterministic and pure → both backends produce identical postings (parity-safe).
|
|
121
|
+
const _isVowel = (s, i) => {
|
|
122
|
+
const c = s.charCodeAt(i);
|
|
123
|
+
// a e i o u
|
|
124
|
+
if (c === 97 || c === 101 || c === 105 || c === 111 || c === 117) return true;
|
|
125
|
+
if (s[i] === 'y') return i === 0 ? true : !_isVowel(s, i - 1);
|
|
126
|
+
return false;
|
|
127
|
+
};
|
|
128
|
+
const _measure = (s) => {
|
|
129
|
+
let n = 0, prevV = false;
|
|
130
|
+
for (let i = 0; i < s.length; i++) { const v = _isVowel(s, i); if (prevV && !v) n++; prevV = v; }
|
|
131
|
+
return n;
|
|
132
|
+
};
|
|
133
|
+
const _hasVowel = (s) => { for (let i = 0; i < s.length; i++) if (_isVowel(s, i)) return true; return false; };
|
|
134
|
+
const _endsDoubleCons = (s) => s.length >= 2 && s[s.length - 1] === s[s.length - 2] && !_isVowel(s, s.length - 1);
|
|
135
|
+
const _cvc = (s) => {
|
|
136
|
+
const n = s.length; if (n < 3) return false;
|
|
137
|
+
if (_isVowel(s, n - 1) || !_isVowel(s, n - 2) || _isVowel(s, n - 3)) return false;
|
|
138
|
+
const c = s[n - 1]; return c !== 'w' && c !== 'x' && c !== 'y';
|
|
139
|
+
};
|
|
140
|
+
const _STEP2 = [['ational', 'ate'], ['tional', 'tion'], ['enci', 'ence'], ['anci', 'ance'], ['izer', 'ize'], ['abli', 'able'], ['alli', 'al'], ['entli', 'ent'], ['eli', 'e'], ['ousli', 'ous'], ['ization', 'ize'], ['ation', 'ate'], ['ator', 'ate'], ['alism', 'al'], ['iveness', 'ive'], ['fulness', 'ful'], ['ousness', 'ous'], ['aliti', 'al'], ['iviti', 'ive'], ['biliti', 'ble'], ['logi', 'log']];
|
|
141
|
+
const _STEP3 = [['icate', 'ic'], ['ative', ''], ['alize', 'al'], ['iciti', 'ic'], ['ical', 'ic'], ['ful', ''], ['ness', '']];
|
|
142
|
+
// Longest-first; 'or' added to Porter's set so agent nouns reduce like '-er'.
|
|
143
|
+
const _STEP4 = ['ement', 'ance', 'ence', 'able', 'ible', 'ment', 'ant', 'ent', 'ion', 'ism', 'ate', 'iti', 'ous', 'ive', 'ize', 'al', 'er', 'or', 'ic', 'ou'];
|
|
144
|
+
|
|
145
|
+
/** Porter stem of a single lowercase word. Returns the input unchanged for short
|
|
146
|
+
* tokens (≤3 chars: acronyms, short API names) so precise symbols never blur. */
|
|
147
|
+
export function stemToken(word) {
|
|
148
|
+
let w = word;
|
|
149
|
+
if (w.length <= 3) return w;
|
|
150
|
+
|
|
151
|
+
if (w.endsWith('sses')) w = w.slice(0, -2);
|
|
152
|
+
else if (w.endsWith('ies')) w = w.slice(0, -2);
|
|
153
|
+
else if (!w.endsWith('ss') && w.endsWith('s')) w = w.slice(0, -1);
|
|
154
|
+
|
|
155
|
+
let fix = false;
|
|
156
|
+
if (w.endsWith('eed')) { if (_measure(w.slice(0, -3)) > 0) w = w.slice(0, -1); }
|
|
157
|
+
else if (w.endsWith('ed') && _hasVowel(w.slice(0, -2))) { w = w.slice(0, -2); fix = true; }
|
|
158
|
+
else if (w.endsWith('ing') && _hasVowel(w.slice(0, -3))) { w = w.slice(0, -3); fix = true; }
|
|
159
|
+
if (fix) {
|
|
160
|
+
if (w.endsWith('at') || w.endsWith('bl') || w.endsWith('iz')) w += 'e';
|
|
161
|
+
else if (_endsDoubleCons(w) && !/[lsz]$/.test(w)) w = w.slice(0, -1);
|
|
162
|
+
else if (_measure(w) === 1 && _cvc(w)) w += 'e';
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (w.length > 2 && w.endsWith('y') && _hasVowel(w.slice(0, -1))) w = w.slice(0, -1) + 'i';
|
|
166
|
+
|
|
167
|
+
for (const [suf, rep] of _STEP2) { if (w.endsWith(suf)) { const st = w.slice(0, -suf.length); if (_measure(st) > 0) w = st + rep; break; } }
|
|
168
|
+
for (const [suf, rep] of _STEP3) { if (w.endsWith(suf)) { const st = w.slice(0, -suf.length); if (_measure(st) > 0) w = st + rep; break; } }
|
|
169
|
+
|
|
170
|
+
for (const suf of _STEP4) {
|
|
171
|
+
if (w.endsWith(suf)) {
|
|
172
|
+
const st = w.slice(0, -suf.length);
|
|
173
|
+
if (suf === 'ion') { if (_measure(st) > 1 && /[st]$/.test(st)) w = st; }
|
|
174
|
+
else if (_measure(st) > 1) w = st;
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (w.endsWith('e')) { const st = w.slice(0, -1); const m = _measure(st); if (m > 1 || (m === 1 && !_cvc(st))) w = st; }
|
|
180
|
+
if (_measure(w) > 1 && w.endsWith('l') && _endsDoubleCons(w)) w = w.slice(0, -1);
|
|
181
|
+
|
|
182
|
+
return w;
|
|
183
|
+
}
|
|
184
|
+
|
|
56
185
|
/**
|
|
57
186
|
* Tokenise code/identifiers: lowercase words plus camelCase sub-parts, so
|
|
58
|
-
* `dispatchRequest` indexes as `dispatchrequest`, `dispatch`, `request`.
|
|
187
|
+
* `dispatchRequest` indexes as `dispatchrequest`, `dispatch`, `request`. Each
|
|
188
|
+
* emitted token also contributes its Porter stem (when different) so behavioural
|
|
189
|
+
* queries bridge the inflection gap to code names — see stemToken. Additive: the
|
|
190
|
+
* raw token is never dropped, so exact match and the boost ladder are unaffected.
|
|
59
191
|
*/
|
|
60
|
-
|
|
192
|
+
// Stem postings live in a separate term namespace (sentinel-prefixed) so a raw
|
|
193
|
+
// query token can NEVER match a stem posting: symbolic/exact lookups stay precisely
|
|
194
|
+
// what they were before stemming existed, and only a query that opts in (emits the
|
|
195
|
+
// prefixed stem) reaches the morphological bridges. Index always emits both; the
|
|
196
|
+
// query emits the prefixed stem only for natural-language queries.
|
|
197
|
+
export const STEM_PREFIX = '~stem~'; // sentinel namespace: raw tokens are [A-Za-z0-9]+, so '~' can never collide
|
|
198
|
+
|
|
199
|
+
export function tokenize(text, stem = true) {
|
|
61
200
|
if (!text) return [];
|
|
62
201
|
const rawTokens = text.split(/[\s\W_]+/);
|
|
63
202
|
const tokens = [];
|
|
203
|
+
const emit = (t) => {
|
|
204
|
+
const lo = t.toLowerCase();
|
|
205
|
+
tokens.push(lo);
|
|
206
|
+
if (!stem) return;
|
|
207
|
+
const s = stemToken(lo);
|
|
208
|
+
if (s !== lo && s.length >= 3) tokens.push(STEM_PREFIX + s);
|
|
209
|
+
};
|
|
64
210
|
for (const word of rawTokens) {
|
|
65
211
|
if (word.length < 2) continue;
|
|
66
|
-
|
|
212
|
+
emit(word);
|
|
67
213
|
const camelParts = word.replace(/([a-z])([A-Z])/g, '$1 $2').split(' ');
|
|
68
214
|
if (camelParts.length > 1) {
|
|
69
215
|
for (const part of camelParts) {
|
|
70
|
-
if (part.length >= 2)
|
|
216
|
+
if (part.length >= 2) emit(part);
|
|
71
217
|
}
|
|
72
218
|
}
|
|
73
219
|
}
|
|
@@ -84,10 +230,14 @@ export function tokenize(text) {
|
|
|
84
230
|
*/
|
|
85
231
|
export const SUMMARY_VEC_SUFFIX = '|s';
|
|
86
232
|
|
|
87
|
-
/** The text embedded as the summary-only vector, or null when not enriched.
|
|
233
|
+
/** The text embedded as the summary-only vector, or null when not enriched.
|
|
234
|
+
* The leading LLM summary carries the behavioural signal; the concept tags add
|
|
235
|
+
* domain vocabulary; the qualified name (class_context.name when present) anchors
|
|
236
|
+
* the vector to the symbol so a method is disambiguated from same-named peers. */
|
|
88
237
|
export function summaryEmbeddingText(chunk) {
|
|
89
238
|
if (!chunk.summary && !(chunk.concepts?.length > 0)) return null;
|
|
90
|
-
|
|
239
|
+
const qualifiedName = chunk.class_context ? `${chunk.class_context}.${chunk.name}` : chunk.name;
|
|
240
|
+
return [chunk.summary, (chunk.concepts || []).join(', '), qualifiedName]
|
|
91
241
|
.filter(Boolean).join('. ');
|
|
92
242
|
}
|
|
93
243
|
|
|
@@ -98,7 +248,7 @@ export function summaryEmbeddingText(chunk) {
|
|
|
98
248
|
*
|
|
99
249
|
* Single source of truth shared by the in-memory engine, the watch daemon and the
|
|
100
250
|
* SQLite writer so every backend indexes identical text. Decorators are
|
|
101
|
-
* deliberately excluded — measured to regress framework repos (see
|
|
251
|
+
* deliberately excluded — measured to regress framework repos (see engine/memory.mjs).
|
|
102
252
|
* When enrichment ran, chunk.concepts contains domain keyword strings that bridge
|
|
103
253
|
* lexical gaps (e.g. "authentication JWT middleware"); chunk.hyde is concepts.join(' ')
|
|
104
254
|
* and serves as the backward-compatible field for existing serialized indexes.
|
|
@@ -133,7 +283,7 @@ export function buildLexicalDocument(chunk, depRelPaths = []) {
|
|
|
133
283
|
// prose, so heavy length normalisation wrongly penalises long implementations vs
|
|
134
284
|
// short export stubs.
|
|
135
285
|
export const BM25_K1 = 1.5;
|
|
136
|
-
export const BM25_B
|
|
286
|
+
export const BM25_B = 0.3;
|
|
137
287
|
|
|
138
288
|
/** Okapi IDF — always positive, avoids negative IDF for very common terms. */
|
|
139
289
|
export function okapiIdf(docCount, docFreq) {
|
|
@@ -180,14 +330,43 @@ export function finalizeVectorCandidates(entries, cap = VECTOR_FUSION_CAP) {
|
|
|
180
330
|
.map(([id, score], i) => ({ id, score, rank: i + 1 }));
|
|
181
331
|
}
|
|
182
332
|
|
|
333
|
+
// Non-natural-language channel weights (keyword / symbol-lookup queries). The
|
|
334
|
+
// vector weight was lowered 1.0 → 0.7 after the 15-fixture search-eval was fixed
|
|
335
|
+
// to actually exercise embeddings (its "semantic" pass had been silently lexical,
|
|
336
|
+
// because the test bridge forced embeddings off — so the prior 1.0 was tuned
|
|
337
|
+
// against a vector-less harness). With honest measurement, a full-strength vector
|
|
338
|
+
// channel slightly OVER-contributes on keyword queries (BM25's home turf): it
|
|
339
|
+
// displaces a confident exact-name lexical rank-1 with a same-prefixed sibling
|
|
340
|
+
// (`Map`→`key`, `GardenPlantingRepository`→`…Dao`). 0.7 is the top of the winning
|
|
341
|
+
// plateau — it restores those rank-1s (broad kw MRR 0.569 → 0.588, +1 hit@1) while
|
|
342
|
+
// keeping the vector channel's recall lift, and leaves the 5-suite repo-eval
|
|
343
|
+
// byte-identical (symbolic 0.78/0.83). Below 0.7 the repo-eval symbolic starts to
|
|
344
|
+
// erode; the NL path keeps its own (higher) vector weight below.
|
|
183
345
|
const LEXICAL_WEIGHT = 1.5;
|
|
184
|
-
const VECTOR_WEIGHT
|
|
185
|
-
|
|
186
|
-
// Natural-language queries
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
|
|
190
|
-
|
|
346
|
+
const VECTOR_WEIGHT = 0.7;
|
|
347
|
+
|
|
348
|
+
// Natural-language queries keep the lexical channel at full strength (exact-name and
|
|
349
|
+
// keyword matches are the most reliable signal); the vector channel's weight depends on
|
|
350
|
+
// whether the corpus carries LLM enrichment:
|
|
351
|
+
//
|
|
352
|
+
// • PLAIN corpus (no enrichment) — the only vectors are raw-code embeddings, a weak
|
|
353
|
+
// conceptual signal that DISPLACES correct lexical hits at any real weight. It acts
|
|
354
|
+
// as a low-weight rescue (0.4). This was the historical "joint optimum" finding.
|
|
355
|
+
// • ENRICHED corpus — every central chunk also owns a summary vector that speaks the
|
|
356
|
+
// vocabulary of behavioural queries, so the vector channel becomes a strong semantic
|
|
357
|
+
// signal and earns more weight. The value is the JOINT optimum of two independent
|
|
358
|
+
// suites that DISAGREE: the repo-eval behavioural channel keeps climbing to ~1.0,
|
|
359
|
+
// while the broader 15-fixture search-eval (mixed nl/kw/xc) peaks at 0.4 and decays
|
|
360
|
+
// above it (a high weight lets a confident-but-wrong summary vector displace a good
|
|
361
|
+
// lexical hit on cross-cutting queries). 0.6 improves BOTH over baseline and avoids
|
|
362
|
+
// overfitting either; the opt-in LLM reranker recovers the top-rank precision that a
|
|
363
|
+
// lower weight gives up. (Plain stays 0.4 — raw-code vectors are a weak rescue.)
|
|
364
|
+
//
|
|
365
|
+
// The engine selects the regime per index via `corpusEnriched` (see engine/memory.mjs /
|
|
366
|
+
// engine/sqlite.mjs), so a repo indexed without --enrich is never hurt by the strong weight.
|
|
367
|
+
const NL_LEXICAL_WEIGHT = 1.5;
|
|
368
|
+
const NL_VECTOR_WEIGHT_PLAIN = 0.4;
|
|
369
|
+
const NL_VECTOR_WEIGHT_ENRICHED = 0.6;
|
|
191
370
|
|
|
192
371
|
const QUERY_STOPWORDS = new Set([
|
|
193
372
|
'the', 'a', 'an', 'and', 'or', 'of', 'to', 'in', 'for', 'on', 'with', 'at',
|
|
@@ -209,10 +388,16 @@ export function isNaturalLanguageQuery(queryText) {
|
|
|
209
388
|
return stops >= 2 && stops / words.length >= 0.2;
|
|
210
389
|
}
|
|
211
390
|
|
|
212
|
-
export const TEST_FILE_RE = /\.(test|spec)\.|[/\\]__tests__[/\\]|_test\.|^tests?[/\\]|[/\\]tests?[/\\]|[/\\]spec[/\\]
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
|
|
391
|
+
export const TEST_FILE_RE = /\.(test|spec)\.|[/\\]__tests__[/\\]|_test\.|^tests?[/\\]|[/\\]tests?[/\\]|[/\\]spec[/\\]/i;
|
|
392
|
+
// Example/sample/demo/sandbox/etc. trees are a top-level project convention
|
|
393
|
+
// (examples/, samples/, demo/, docs_src/, …). The keyword is matched ONLY as the
|
|
394
|
+
// FIRST or SECOND path segment. Matching it at ANY depth (the previous behaviour)
|
|
395
|
+
// false-positived on PACKAGE PATHS that merely contain the word — e.g. Spring's
|
|
396
|
+
// `src/main/java/org/springframework/samples/petclinic/…` and Google's
|
|
397
|
+
// `com/google/samples/apps/…` — silently excluding real application code from LLM
|
|
398
|
+
// enrichment and demoting it in search. The fixtures' legitimate example/sample
|
|
399
|
+
// dirs all sit at depth ≤1, so this only stops the deep package-path false hits.
|
|
400
|
+
export const EXAMPLE_DIR_RE = /^(?:[^/\\]+[/\\])?(?:examples?|samples?|demos?|tutorials?|docs_src|sandbox|benchmarks?|scripts?)[/\\]/;
|
|
216
401
|
|
|
217
402
|
/**
|
|
218
403
|
* Reciprocal-Rank-Fusion of lexical + vector candidate lists, with the measured
|
|
@@ -239,13 +424,31 @@ export const EXAMPLE_DIR_RE = /^examples?[/\\]|[/\\]examples?[/\\]|^samples?[/\\
|
|
|
239
424
|
export function fuseAndRank({
|
|
240
425
|
lexicalResults, vectorResults, getChunk, getPathTokens, getDf,
|
|
241
426
|
docCount, rrfK, topK, queryText, exactBoostName = null, resolveExact = null,
|
|
427
|
+
corpusEnriched = false,
|
|
242
428
|
}) {
|
|
243
|
-
const rrfScores
|
|
244
|
-
const K
|
|
429
|
+
const rrfScores = new Map();
|
|
430
|
+
const K = rrfK;
|
|
245
431
|
const queryLower = queryText.toLowerCase();
|
|
246
432
|
|
|
247
433
|
const _queryPathTokens = queryLower.split(/[\s\W_]+/).filter(t => t.length >= 3);
|
|
248
434
|
|
|
435
|
+
// File-path boost discriminativeness gate (natural-language queries only).
|
|
436
|
+
// The file-path boost (further down) fires when a query token matches a
|
|
437
|
+
// filename segment — but a common low-IDF word ("path", "url", "config",
|
|
438
|
+
// "index") that merely appears in a filename then over-promotes EVERY chunk in
|
|
439
|
+
// that file (an NL query mentioning "path" boosts all of path.go, burying the
|
|
440
|
+
// real answer that lives elsewhere). Restrict the boost to query terms
|
|
441
|
+
// discriminative enough to be a genuine filename signal: IDF ≥ ln(docCount/2),
|
|
442
|
+
// a per-corpus structural threshold (not tuned to any query). Symbolic/keyword
|
|
443
|
+
// queries are excluded by the NL gate and keep the original token set
|
|
444
|
+
// byte-for-byte. Measured: lifts agent-style recall@5 (overall s@5 0.77→0.81;
|
|
445
|
+
// tuning-semantic s@5 0.52→0.61) with symbolic rankings unchanged, no regression.
|
|
446
|
+
const _pathBoostIsNL = isNaturalLanguageQuery(queryText);
|
|
447
|
+
const _pathBoostMinIdf = Math.log(Math.max(docCount, 2) / 2);
|
|
448
|
+
const _queryPathTokensBoost = _pathBoostIsNL
|
|
449
|
+
? _queryPathTokens.filter(t => okapiIdf(docCount, getDf(t)) >= _pathBoostMinIdf)
|
|
450
|
+
: _queryPathTokens;
|
|
451
|
+
|
|
249
452
|
// Name-boost eligibility: long tokens, or short-but-discriminative ones whose
|
|
250
453
|
// document frequency is ≤15% of the corpus (self-tunes per repo/language,
|
|
251
454
|
// surfacing exact matches on short API names without re-introducing stopwords).
|
|
@@ -258,8 +461,9 @@ export function fuseAndRank({
|
|
|
258
461
|
// vector-led for natural-language behavioural queries (only when a vector
|
|
259
462
|
// channel actually produced candidates — lexical-only mode is unaffected).
|
|
260
463
|
const nlQuery = vectorResults.length > 0 && isNaturalLanguageQuery(queryText);
|
|
464
|
+
const nlVecWeight = corpusEnriched ? NL_VECTOR_WEIGHT_ENRICHED : NL_VECTOR_WEIGHT_PLAIN;
|
|
261
465
|
const wLex = nlQuery ? NL_LEXICAL_WEIGHT : LEXICAL_WEIGHT;
|
|
262
|
-
const wVec = nlQuery ?
|
|
466
|
+
const wVec = nlQuery ? nlVecWeight : VECTOR_WEIGHT;
|
|
263
467
|
|
|
264
468
|
// Re-rank the vector channel with test/example demotion applied to the cosine
|
|
265
469
|
// scores BEFORE ranks are assigned: a test helper that out-scores the real
|
|
@@ -280,11 +484,22 @@ export function fuseAndRank({
|
|
|
280
484
|
vectorResults = adjusted.map((r, i) => ({ ...r, rank: i + 1 }));
|
|
281
485
|
}
|
|
282
486
|
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
//
|
|
286
|
-
//
|
|
287
|
-
|
|
487
|
+
// Boosts are NOT gated on vector-candidate membership. Gating was intended to
|
|
488
|
+
// stop generic-word name collisions on NL queries, but because the code-embedding
|
|
489
|
+
// channel is weak it mostly suppressed the EXACT-name boost on the correct hit
|
|
490
|
+
// (which then sank below a noisy vector candidate). Removing the gate measured
|
|
491
|
+
// strictly better on both retrieval suites; the exact-name boost is the single
|
|
492
|
+
// most reliable signal and must always apply.
|
|
493
|
+
const boostEligible = null;
|
|
494
|
+
|
|
495
|
+
// Style-intent detection: a stylesheet bundle vendored into a source tree
|
|
496
|
+
// (e.g. a bundled bootstrap.css) produces hundreds of synthetic-named
|
|
497
|
+
// `*_rule_set` chunks that pollute CODE searches in mixed repos. Demote those
|
|
498
|
+
// synthetic rule_sets UNLESS the query is actually about styling (a selector
|
|
499
|
+
// char, an exact_tokens pin, or a style keyword). Named SCSS @mixin/@function
|
|
500
|
+
// chunks are unaffected — only nameless rule_sets are noise for code queries.
|
|
501
|
+
const queryIsStyle = exactBoostName != null ||
|
|
502
|
+
/[.#]|css|scss|stylesheet|selector|\bstyle\b|\bclass\b|color|margin|padding|font|border|background|hover|keyframe|animation|mixin|breakpoint|responsive|\bwidth\b|\bheight\b/.test(queryLower);
|
|
288
503
|
|
|
289
504
|
const allResults = [
|
|
290
505
|
...vectorResults.map(r => ({ ...r, _w: wVec })),
|
|
@@ -296,14 +511,13 @@ export function fuseAndRank({
|
|
|
296
511
|
const chunk = getChunk(id);
|
|
297
512
|
if (!chunk) continue;
|
|
298
513
|
|
|
299
|
-
// Demotion: test / spec files (unless the query is itself about tests).
|
|
300
514
|
if (TEST_FILE_RE.test(chunk.file_path)) {
|
|
301
515
|
if (!queryLower.includes('test') && !queryLower.includes('spec')) baseScore *= 0.25;
|
|
302
516
|
}
|
|
303
|
-
// Demotion:
|
|
304
|
-
//
|
|
517
|
+
// Demotion: synthetic-named stylesheet rule_sets on non-style queries
|
|
518
|
+
// (vendored CSS bundle noise in mixed repos). Named mixins/functions exempt.
|
|
519
|
+
if (!queryIsStyle && chunk.name && chunk.name.endsWith('_rule_set')) baseScore *= 0.2;
|
|
305
520
|
if (EXAMPLE_DIR_RE.test(chunk.file_path)) baseScore *= 0.5;
|
|
306
|
-
// Demotion: pure expression sites.
|
|
307
521
|
if (chunk.node_type === 'expression_statement' || chunk.node_type === 'call_expression') {
|
|
308
522
|
baseScore *= 0.8;
|
|
309
523
|
}
|
|
@@ -327,14 +541,14 @@ export function fuseAndRank({
|
|
|
327
541
|
// File-path boost via the separate path-token set (not the BM25 index), so
|
|
328
542
|
// length normalisation never penalises long implementations sharing a path.
|
|
329
543
|
const canBoost = !boostEligible || boostEligible.has(id);
|
|
330
|
-
if (canBoost &&
|
|
544
|
+
if (canBoost && _queryPathTokensBoost.length > 0) {
|
|
331
545
|
const pathToks = getPathTokens(id);
|
|
332
546
|
if (pathToks) {
|
|
333
|
-
const hasExact =
|
|
334
|
-
const hasPrefix = !hasExact &&
|
|
547
|
+
const hasExact = _queryPathTokensBoost.some(t => pathToks.has(t));
|
|
548
|
+
const hasPrefix = !hasExact && _queryPathTokensBoost.some(t =>
|
|
335
549
|
t.length >= 4 && Array.from(pathToks).some(pt => pt.startsWith(t.slice(0, 5)))
|
|
336
550
|
);
|
|
337
|
-
if (hasExact)
|
|
551
|
+
if (hasExact) baseScore *= 1.4;
|
|
338
552
|
else if (hasPrefix) baseScore *= 1.2;
|
|
339
553
|
}
|
|
340
554
|
}
|
|
@@ -344,8 +558,8 @@ export function fuseAndRank({
|
|
|
344
558
|
// Plural equivalence (`BackgroundTask` ↔ `BackgroundTasks`) is included —
|
|
345
559
|
// API names pluralize and a strict equality check missed them.
|
|
346
560
|
if (canBoost && chunk.name && chunk.name !== 'anonymous') {
|
|
347
|
-
const nameLower
|
|
348
|
-
const lastDotted
|
|
561
|
+
const nameLower = chunk.name.toLowerCase();
|
|
562
|
+
const lastDotted = nameLower.split('.').pop() ?? nameLower;
|
|
349
563
|
const queryTokensAll = _queryNameTokens;
|
|
350
564
|
const eq = (a, b) => a === b || a === b + 's' || b === a + 's';
|
|
351
565
|
if (queryTokensAll.some(t => eq(nameLower, t) || eq(lastDotted, t))) {
|
|
@@ -355,7 +569,7 @@ export function fuseAndRank({
|
|
|
355
569
|
baseScore *= 2.0;
|
|
356
570
|
} else {
|
|
357
571
|
const snakeParts = nameLower.split(/[._]+/);
|
|
358
|
-
const lastSnake
|
|
572
|
+
const lastSnake = snakeParts[snakeParts.length - 1] ?? '';
|
|
359
573
|
if (snakeParts.length >= 2 && lastSnake.length >= 3 && queryTokensAll.includes(lastSnake)) {
|
|
360
574
|
baseScore *= 1.4;
|
|
361
575
|
}
|
|
@@ -365,7 +579,6 @@ export function fuseAndRank({
|
|
|
365
579
|
rrfScores.set(id, (rrfScores.get(id) || 0) + baseScore);
|
|
366
580
|
}
|
|
367
581
|
|
|
368
|
-
// Optional guaranteed boost for an exactly-named symbol (search_code exact_tokens).
|
|
369
582
|
if (exactBoostName && resolveExact) {
|
|
370
583
|
const boostTerm = String(exactBoostName).toLowerCase().trim();
|
|
371
584
|
for (const id of resolveExact(boostTerm)) {
|
|
@@ -373,8 +586,6 @@ export function fuseAndRank({
|
|
|
373
586
|
}
|
|
374
587
|
}
|
|
375
588
|
|
|
376
|
-
// Deterministic tie-break on id: equal fused scores must order identically
|
|
377
|
-
// on every backend (Map iteration vs SQL row order would otherwise differ).
|
|
378
589
|
return Array.from(rrfScores.entries())
|
|
379
590
|
.sort((a, b) => (b[1] - a[1]) || (a[0] < b[0] ? -1 : 1))
|
|
380
591
|
.slice(0, topK)
|
package/storage.mjs
CHANGED
|
@@ -17,13 +17,38 @@
|
|
|
17
17
|
* getChunksByFile(path) → chunk[]
|
|
18
18
|
* resolveSymbol(name) → chunk[] (exact, case-insensitive)
|
|
19
19
|
* findCallers(funcName) → chunk[]
|
|
20
|
+
* findReferers(symbol) → chunk[] (type_refs / extends matches)
|
|
21
|
+
* findRoutes({method, pathPrefix}) → route[] (HTTP route → handler chunk)
|
|
20
22
|
* iterateChunks() → Iterable<chunk> (cursor on SQLite)
|
|
21
23
|
* getDependencies(path) / getImportedBy(path) → string[]
|
|
22
24
|
* chunkCount() / fileCount() / symbolCount() / vectorCount() → number
|
|
23
25
|
* stats() → engine health facts
|
|
24
26
|
* close() Release fds / db handles.
|
|
25
27
|
*/
|
|
26
|
-
import
|
|
28
|
+
import fs from 'fs';
|
|
29
|
+
import { MemoryGraphIndex } from './engine/memory.mjs';
|
|
30
|
+
|
|
31
|
+
// 'auto' storage keeps the index in memory until a repo is large enough that the
|
|
32
|
+
// disk-backed SQLite store earns its slightly higher per-query latency. The indexer
|
|
33
|
+
// decides by the real chunk count it just built; readers decide by which artifact
|
|
34
|
+
// exists on disk (see resolveBackend).
|
|
35
|
+
export const AUTO_SQLITE_CHUNK_THRESHOLD = 15000;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Resolve the abstract storage setting ('auto'|'memory'|'sqlite') to a concrete
|
|
39
|
+
* read backend. Explicit settings win; 'auto' picks SQLite when a SQLite artifact
|
|
40
|
+
* is present (the indexer writes one only for large repos and removes the other
|
|
41
|
+
* backend's artifact, so presence is unambiguous), else the in-memory JSON index.
|
|
42
|
+
*
|
|
43
|
+
* @param {object} config Resolved config from config.mjs.
|
|
44
|
+
* @returns {'memory'|'sqlite'}
|
|
45
|
+
*/
|
|
46
|
+
export function resolveBackend(config) {
|
|
47
|
+
if (config.storage === 'sqlite') return 'sqlite';
|
|
48
|
+
if (config.storage === 'memory') return 'memory';
|
|
49
|
+
try { if (fs.existsSync(config.sqlitePath)) return 'sqlite'; } catch { /* fall through to memory */ }
|
|
50
|
+
return 'memory';
|
|
51
|
+
}
|
|
27
52
|
|
|
28
53
|
/**
|
|
29
54
|
* Construct (but do not yet load) the configured store.
|
|
@@ -34,9 +59,9 @@ import { MemoryGraphIndex } from './core-engine.mjs';
|
|
|
34
59
|
* @returns {Promise<object>} a store implementing the contract above.
|
|
35
60
|
*/
|
|
36
61
|
export async function createStore(config, { cacheEmbeddings = false } = {}) {
|
|
37
|
-
if (config
|
|
62
|
+
if (resolveBackend(config) === 'sqlite') {
|
|
38
63
|
// Imported lazily so the default path never loads node:sqlite.
|
|
39
|
-
const { SqliteGraphStore } = await import('./sqlite
|
|
64
|
+
const { SqliteGraphStore } = await import('./engine/sqlite.mjs');
|
|
40
65
|
return new SqliteGraphStore(config.sqlitePath, { embeddingPath: config.embeddingPath });
|
|
41
66
|
}
|
|
42
67
|
return new MemoryGraphIndex(config.indexPath, { cacheEmbeddings });
|