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/search-core.mjs
ADDED
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file search-core.mjs
|
|
3
|
+
* @description Storage-agnostic retrieval primitives shared by every backend:
|
|
4
|
+
* tokenisation, cosine similarity, BM25 scoring, the RRF fusion +
|
|
5
|
+
* boost ladder, and PageRank centrality. Keeping this math in one
|
|
6
|
+
* place is what lets the in-memory engine and the SQLite store
|
|
7
|
+
* return identical rankings — the numbers are measured once and
|
|
8
|
+
* reused, never re-derived per backend.
|
|
9
|
+
* @author MaquinaTech <https://github.com/MaquinaTech>
|
|
10
|
+
* @copyright (c) 2026 MaquinaTech. All rights reserved.
|
|
11
|
+
* @license MIT
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { createHash } from 'crypto';
|
|
15
|
+
|
|
16
|
+
export const EMBEDDING_CONTEXT_LIMIT = 8000;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Cache key for a chunk's embedding vector.
|
|
20
|
+
*
|
|
21
|
+
* The embedding payload includes the LLM enrichment summary when present, so a
|
|
22
|
+
* vector computed for an enriched chunk is NOT interchangeable with one computed
|
|
23
|
+
* for the same code without enrichment. Keying by content_hash alone caused two
|
|
24
|
+
* defects: enriched chunks were re-embedded on every index run (cache never hit),
|
|
25
|
+
* and a stale un-enriched vector could be silently reused for an enriched chunk.
|
|
26
|
+
*
|
|
27
|
+
* The key is content_hash for plain chunks, and content_hash + a digest of the
|
|
28
|
+
* enrichment text for enriched ones — deterministic as long as the enrichment
|
|
29
|
+
* cache returns the same summary for the same code.
|
|
30
|
+
*
|
|
31
|
+
* @param {object} chunk
|
|
32
|
+
* @returns {string}
|
|
33
|
+
*/
|
|
34
|
+
export function embeddingKeyFor(chunk) {
|
|
35
|
+
const hasEnrichment = Boolean(chunk.summary) || (chunk.concepts?.length > 0);
|
|
36
|
+
if (!hasEnrichment) return chunk.content_hash;
|
|
37
|
+
const enrichText = `${chunk.summary || ''}|${(chunk.concepts || []).join(',')}`;
|
|
38
|
+
const suffix = createHash('sha256').update(enrichText).digest('hex').slice(0, 12);
|
|
39
|
+
return `${chunk.content_hash}|e:${suffix}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function truncateForEmbedding(text) {
|
|
43
|
+
return text.length > EMBEDDING_CONTEXT_LIMIT ? text.slice(0, EMBEDDING_CONTEXT_LIMIT) : text;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function cosineSimilarity(vecA, vecB) {
|
|
47
|
+
let dot = 0, nA = 0, nB = 0;
|
|
48
|
+
for (let i = 0; i < vecA.length; i++) {
|
|
49
|
+
dot += vecA[i] * vecB[i];
|
|
50
|
+
nA += vecA[i] * vecA[i];
|
|
51
|
+
nB += vecB[i] * vecB[i];
|
|
52
|
+
}
|
|
53
|
+
return nA === 0 || nB === 0 ? 0 : dot / (Math.sqrt(nA) * Math.sqrt(nB));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Tokenise code/identifiers: lowercase words plus camelCase sub-parts, so
|
|
58
|
+
* `dispatchRequest` indexes as `dispatchrequest`, `dispatch`, `request`.
|
|
59
|
+
*/
|
|
60
|
+
export function tokenize(text) {
|
|
61
|
+
if (!text) return [];
|
|
62
|
+
const rawTokens = text.split(/[\s\W_]+/);
|
|
63
|
+
const tokens = [];
|
|
64
|
+
for (const word of rawTokens) {
|
|
65
|
+
if (word.length < 2) continue;
|
|
66
|
+
tokens.push(word.toLowerCase());
|
|
67
|
+
const camelParts = word.replace(/([a-z])([A-Z])/g, '$1 $2').split(' ');
|
|
68
|
+
if (camelParts.length > 1) {
|
|
69
|
+
for (const part of camelParts) {
|
|
70
|
+
if (part.length >= 2) tokens.push(part.toLowerCase());
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return tokens;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Suffix appended to an embedding key for a chunk's summary-only vector.
|
|
79
|
+
* Enriched chunks carry TWO vectors in the bin: the full code payload (base key)
|
|
80
|
+
* and this compact summary+concepts text. A one-line natural-language query is
|
|
81
|
+
* far closer in embedding space to a one-line summary than to 700 chars of code,
|
|
82
|
+
* so the summary vector is what lets behavioural queries hit code that shares
|
|
83
|
+
* none of their words. Scan hits on `<key>|s` map back to the same chunk.
|
|
84
|
+
*/
|
|
85
|
+
export const SUMMARY_VEC_SUFFIX = '|s';
|
|
86
|
+
|
|
87
|
+
/** The text embedded as the summary-only vector, or null when not enriched. */
|
|
88
|
+
export function summaryEmbeddingText(chunk) {
|
|
89
|
+
if (!chunk.summary && !(chunk.concepts?.length > 0)) return null;
|
|
90
|
+
return [chunk.summary, (chunk.concepts || []).join(', '), chunk.name]
|
|
91
|
+
.filter(Boolean).join('. ');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Build the lexical document indexed for BM25. Concatenates the discriminative
|
|
96
|
+
* fields of a chunk (name, docstring, neighbour basenames, calls, params, return
|
|
97
|
+
* type, qualified name, type refs, LLM concept tags, body).
|
|
98
|
+
*
|
|
99
|
+
* Single source of truth shared by the in-memory engine, the watch daemon and the
|
|
100
|
+
* SQLite writer so every backend indexes identical text. Decorators are
|
|
101
|
+
* deliberately excluded — measured to regress framework repos (see core-engine).
|
|
102
|
+
* When enrichment ran, chunk.concepts contains domain keyword strings that bridge
|
|
103
|
+
* lexical gaps (e.g. "authentication JWT middleware"); chunk.hyde is concepts.join(' ')
|
|
104
|
+
* and serves as the backward-compatible field for existing serialized indexes.
|
|
105
|
+
*
|
|
106
|
+
* @param {object} chunk
|
|
107
|
+
* @param {string[]} depRelPaths Resolved local imports of the chunk's file.
|
|
108
|
+
* @returns {string}
|
|
109
|
+
*/
|
|
110
|
+
export function buildLexicalDocument(chunk, depRelPaths = []) {
|
|
111
|
+
const cleanDeps = depRelPaths.map(d => d.split('/').pop().split('.')[0]);
|
|
112
|
+
// Prefer the structured concepts array (new); fall back to the joined hyde string
|
|
113
|
+
// (old serialized indexes) so both formats index identical terms.
|
|
114
|
+
const conceptTokens = chunk.concepts?.length
|
|
115
|
+
? chunk.concepts.join(' ')
|
|
116
|
+
: (chunk.hyde || '');
|
|
117
|
+
return [
|
|
118
|
+
chunk.name,
|
|
119
|
+
chunk.docstring || '',
|
|
120
|
+
cleanDeps.join(' '),
|
|
121
|
+
(chunk.calls || []).join(' '),
|
|
122
|
+
(chunk.params || []).join(' '),
|
|
123
|
+
chunk.return_type || '',
|
|
124
|
+
chunk.class_context ? `${chunk.class_context}.${chunk.name}` : '',
|
|
125
|
+
(chunk.type_refs || []).join(' '),
|
|
126
|
+
conceptTokens, // domain concept keywords from LLM enrichment (opt-in)
|
|
127
|
+
chunk.code_snippet,
|
|
128
|
+
].join(' ');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ─── BM25 (tuned for code) ─────────────────────────────────────────────────────
|
|
132
|
+
// b lowered from 0.75 → 0.3: code chunks have purposeful length variation unlike
|
|
133
|
+
// prose, so heavy length normalisation wrongly penalises long implementations vs
|
|
134
|
+
// short export stubs.
|
|
135
|
+
export const BM25_K1 = 1.5;
|
|
136
|
+
export const BM25_B = 0.3;
|
|
137
|
+
|
|
138
|
+
/** Okapi IDF — always positive, avoids negative IDF for very common terms. */
|
|
139
|
+
export function okapiIdf(docCount, docFreq) {
|
|
140
|
+
return Math.log((docCount - docFreq + 0.5) / (docFreq + 0.5) + 1);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** BM25 term contribution: diminishing TF returns + document-length normalisation. */
|
|
144
|
+
export function bm25Score(idf, tf, docLen, avgdl, k1 = BM25_K1, b = BM25_B) {
|
|
145
|
+
return idf * (tf * (k1 + 1)) / (tf + k1 * (1 - b + b * docLen / avgdl));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ─── Hybrid fusion (RRF + boost ladder) ────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
// Lexical candidates handed to fusion — shared by BOTH backends. An asymmetric
|
|
151
|
+
// cap would let deep-ranked chunks earn RRF contributions on one backend only,
|
|
152
|
+
// silently breaking rank parity.
|
|
153
|
+
export const LEXICAL_FUSION_CAP = 2000;
|
|
154
|
+
|
|
155
|
+
// Unique vector candidates handed to fusion (after summary-vector dedupe).
|
|
156
|
+
export const VECTOR_FUSION_CAP = 200;
|
|
157
|
+
// Raw bin entries to collect before dedupe: each chunk owns at most TWO entries
|
|
158
|
+
// (code vector + summary vector), so 2× the cap guarantees the full unique set.
|
|
159
|
+
export const VECTOR_SCAN_RAW_N = VECTOR_FUSION_CAP * 2;
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Normalize a store's raw vector hits into the fusion-ready candidate list:
|
|
163
|
+
* best score per chunk id, deterministic (score desc, id asc) order, capped.
|
|
164
|
+
* EVERY backend path (eager matrix, streaming scan, binary sketch) must funnel
|
|
165
|
+
* through this so the vector channel is rank-identical across stores — dedupe
|
|
166
|
+
* scope and tie order were measured to silently diverge otherwise.
|
|
167
|
+
*
|
|
168
|
+
* @param {Array<{id:string, score:number}>} entries May contain duplicate ids.
|
|
169
|
+
* @returns {Array<{id:string, score:number, rank:number}>}
|
|
170
|
+
*/
|
|
171
|
+
export function finalizeVectorCandidates(entries, cap = VECTOR_FUSION_CAP) {
|
|
172
|
+
const best = new Map();
|
|
173
|
+
for (const e of entries) {
|
|
174
|
+
const prev = best.get(e.id);
|
|
175
|
+
if (prev === undefined || e.score > prev) best.set(e.id, e.score);
|
|
176
|
+
}
|
|
177
|
+
return Array.from(best.entries())
|
|
178
|
+
.sort((a, b) => (b[1] - a[1]) || (a[0] < b[0] ? -1 : 1))
|
|
179
|
+
.slice(0, cap)
|
|
180
|
+
.map(([id, score], i) => ({ id, score, rank: i + 1 }));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const LEXICAL_WEIGHT = 1.5;
|
|
184
|
+
const VECTOR_WEIGHT = 1.0;
|
|
185
|
+
|
|
186
|
+
// Natural-language queries flip the channel weights: behavioural descriptions
|
|
187
|
+
// ("how does the app parse incoming JSON payloads?") carry their signal in the
|
|
188
|
+
// embedding, while their common English words only add BM25 noise.
|
|
189
|
+
const NL_LEXICAL_WEIGHT = 1.0;
|
|
190
|
+
const NL_VECTOR_WEIGHT = 1.6;
|
|
191
|
+
|
|
192
|
+
const QUERY_STOPWORDS = new Set([
|
|
193
|
+
'the', 'a', 'an', 'and', 'or', 'of', 'to', 'in', 'for', 'on', 'with', 'at',
|
|
194
|
+
'by', 'from', 'that', 'this', 'is', 'are', 'was', 'were', 'be', 'how', 'what',
|
|
195
|
+
'when', 'where', 'which', 'who', 'why', 'does', 'do', 'it', 'its', 'into',
|
|
196
|
+
'back', 'if', 'then', 'their', 'there', 'all', 'any',
|
|
197
|
+
]);
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Heuristic: is this an agent-style natural-language query (a behavioural
|
|
201
|
+
* description / question) rather than a keyword or symbol lookup? NL queries are
|
|
202
|
+
* long and contain English function words; symbol queries ("ShouldBindJSON bind
|
|
203
|
+
* request body") contain none.
|
|
204
|
+
*/
|
|
205
|
+
export function isNaturalLanguageQuery(queryText) {
|
|
206
|
+
const words = String(queryText).toLowerCase().split(/[\s\W_]+/).filter(Boolean);
|
|
207
|
+
if (words.length < 5) return false;
|
|
208
|
+
const stops = words.filter(w => QUERY_STOPWORDS.has(w)).length;
|
|
209
|
+
return stops >= 2 && stops / words.length >= 0.2;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export const TEST_FILE_RE = /\.(test|spec)\.|[/\\]__tests__[/\\]|_test\.|^tests?[/\\]|[/\\]tests?[/\\]|[/\\]spec[/\\]/;
|
|
213
|
+
// `^integration/` is root-anchored ONLY: a repo-root integration/ tree is an e2e
|
|
214
|
+
// test-app convention (NestJS), while nested src/integrations/ is real code.
|
|
215
|
+
export const EXAMPLE_DIR_RE = /^examples?[/\\]|[/\\]examples?[/\\]|^samples?[/\\]|[/\\]samples?[/\\]|^demos?[/\\]|[/\\]demos?[/\\]|[/\\]tutorials?[/\\]|^docs_src[/\\]|[/\\]docs_src[/\\]|^sandbox[/\\]|[/\\]sandbox[/\\]|^benchmarks?[/\\]|[/\\]benchmarks?[/\\]|^scripts?[/\\]|[/\\]scripts?[/\\]/;
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Reciprocal-Rank-Fusion of lexical + vector candidate lists, with the measured
|
|
219
|
+
* boost ladder (test/example demotion, expression demotion, TS barrel demotion,
|
|
220
|
+
* file-path boost, exact/snake name boost, optional exact-symbol boost).
|
|
221
|
+
*
|
|
222
|
+
* All backend-specific data is reached through accessors so the identical math
|
|
223
|
+
* serves both the in-memory Maps and SQLite row lookups.
|
|
224
|
+
*
|
|
225
|
+
* @param {object} p
|
|
226
|
+
* @param {Array<{id:string,rank:number}>} p.lexicalResults
|
|
227
|
+
* @param {Array<{id:string,rank:number}>} p.vectorResults
|
|
228
|
+
* @param {(id:string)=>object|undefined} p.getChunk
|
|
229
|
+
* @param {(id:string)=>Set<string>|undefined} p.getPathTokens
|
|
230
|
+
* @param {(token:string)=>number} p.getDf Document frequency of a token.
|
|
231
|
+
* @param {number} p.docCount
|
|
232
|
+
* @param {number} p.rrfK
|
|
233
|
+
* @param {number} p.topK
|
|
234
|
+
* @param {string} p.queryText
|
|
235
|
+
* @param {string|null} [p.exactBoostName]
|
|
236
|
+
* @param {(termLower:string)=>Iterable<string>} [p.resolveExact] Ids whose name === term.
|
|
237
|
+
* @returns {Array<{score:number, chunk:object}>}
|
|
238
|
+
*/
|
|
239
|
+
export function fuseAndRank({
|
|
240
|
+
lexicalResults, vectorResults, getChunk, getPathTokens, getDf,
|
|
241
|
+
docCount, rrfK, topK, queryText, exactBoostName = null, resolveExact = null,
|
|
242
|
+
}) {
|
|
243
|
+
const rrfScores = new Map();
|
|
244
|
+
const K = rrfK;
|
|
245
|
+
const queryLower = queryText.toLowerCase();
|
|
246
|
+
|
|
247
|
+
const _queryPathTokens = queryLower.split(/[\s\W_]+/).filter(t => t.length >= 3);
|
|
248
|
+
|
|
249
|
+
// Name-boost eligibility: long tokens, or short-but-discriminative ones whose
|
|
250
|
+
// document frequency is ≤15% of the corpus (self-tunes per repo/language,
|
|
251
|
+
// surfacing exact matches on short API names without re-introducing stopwords).
|
|
252
|
+
const _docN = docCount || 1;
|
|
253
|
+
const _queryNameTokens = queryLower.split(/[\s\W_]+/).filter(t =>
|
|
254
|
+
t.length >= 5 || (t.length >= 3 && (getDf(t) || 0) <= 0.15 * _docN)
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
// Query-adaptive channel weights: lexical-led for keyword/symbol lookups,
|
|
258
|
+
// vector-led for natural-language behavioural queries (only when a vector
|
|
259
|
+
// channel actually produced candidates — lexical-only mode is unaffected).
|
|
260
|
+
const nlQuery = vectorResults.length > 0 && isNaturalLanguageQuery(queryText);
|
|
261
|
+
const wLex = nlQuery ? NL_LEXICAL_WEIGHT : LEXICAL_WEIGHT;
|
|
262
|
+
const wVec = nlQuery ? NL_VECTOR_WEIGHT : VECTOR_WEIGHT;
|
|
263
|
+
|
|
264
|
+
// Re-rank the vector channel with test/example demotion applied to the cosine
|
|
265
|
+
// scores BEFORE ranks are assigned: a test helper that out-scores the real
|
|
266
|
+
// implementation in raw cosine otherwise occupies the top RRF positions and
|
|
267
|
+
// pushes the right answer's reciprocal rank down, even though the boost
|
|
268
|
+
// ladder demotes the helper later.
|
|
269
|
+
if (vectorResults.length > 0) {
|
|
270
|
+
const adjusted = vectorResults.map(r => {
|
|
271
|
+
const c = getChunk(r.id);
|
|
272
|
+
let f = 1;
|
|
273
|
+
if (c) {
|
|
274
|
+
if (TEST_FILE_RE.test(c.file_path) && !queryLower.includes('test') && !queryLower.includes('spec')) f = 0.25;
|
|
275
|
+
else if (EXAMPLE_DIR_RE.test(c.file_path)) f = 0.5;
|
|
276
|
+
}
|
|
277
|
+
return { id: r.id, score: (r.score ?? 0) * f };
|
|
278
|
+
});
|
|
279
|
+
adjusted.sort((a, b) => (b.score - a.score) || (a.id < b.id ? -1 : 1));
|
|
280
|
+
vectorResults = adjusted.map((r, i) => ({ ...r, rank: i + 1 }));
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// On NL queries the name/path boosts misfire: identifiers that happen to share
|
|
284
|
+
// a generic English word with the query ("_global" for "global configuration")
|
|
285
|
+
// get 1.4–2.0× promotions over the semantically right answer. Gate the boosts
|
|
286
|
+
// on semantic agreement — a chunk must also be a vector candidate to earn them.
|
|
287
|
+
const boostEligible = nlQuery ? new Set(vectorResults.map(r => r.id)) : null;
|
|
288
|
+
|
|
289
|
+
const allResults = [
|
|
290
|
+
...vectorResults.map(r => ({ ...r, _w: wVec })),
|
|
291
|
+
...lexicalResults.map(r => ({ ...r, _w: wLex })),
|
|
292
|
+
];
|
|
293
|
+
|
|
294
|
+
for (const { id, rank, _w } of allResults) {
|
|
295
|
+
let baseScore = (_w ?? 1.0) / (K + rank);
|
|
296
|
+
const chunk = getChunk(id);
|
|
297
|
+
if (!chunk) continue;
|
|
298
|
+
|
|
299
|
+
// Demotion: test / spec files (unless the query is itself about tests).
|
|
300
|
+
if (TEST_FILE_RE.test(chunk.file_path)) {
|
|
301
|
+
if (!queryLower.includes('test') && !queryLower.includes('spec')) baseScore *= 0.25;
|
|
302
|
+
}
|
|
303
|
+
// Demotion: example / docs dirs (tutorial snippets over-rank on short length
|
|
304
|
+
// + high keyword density vs the real implementation).
|
|
305
|
+
if (EXAMPLE_DIR_RE.test(chunk.file_path)) baseScore *= 0.5;
|
|
306
|
+
// Demotion: pure expression sites.
|
|
307
|
+
if (chunk.node_type === 'expression_statement' || chunk.node_type === 'call_expression') {
|
|
308
|
+
baseScore *= 0.8;
|
|
309
|
+
}
|
|
310
|
+
// Demotion: TypeScript barrel re-exports (`export { X } from 'y'`) — no
|
|
311
|
+
// implementation. JS exports are excluded since those often ARE the module.
|
|
312
|
+
if (chunk.name && chunk.name.endsWith('_export_statement') && chunk.file_path?.endsWith('.ts')) {
|
|
313
|
+
baseScore *= 0.7;
|
|
314
|
+
}
|
|
315
|
+
// Demotion: Python public re-exports (`from x import Y as Y`) — but only
|
|
316
|
+
// when a REAL definition of the same name exists in the index. When the
|
|
317
|
+
// re-export is the only in-repo occurrence (the implementation lives in a
|
|
318
|
+
// dependency, e.g. fastapi re-exporting starlette's BackgroundTasks), the
|
|
319
|
+
// alias IS the best available answer and must not be demoted.
|
|
320
|
+
if (chunk.node_type === 're_export' && resolveExact && chunk.name) {
|
|
321
|
+
for (const rid of resolveExact(String(chunk.name).toLowerCase())) {
|
|
322
|
+
const rc = getChunk(rid);
|
|
323
|
+
if (rc && rc.node_type !== 're_export') { baseScore *= 0.7; break; }
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// File-path boost via the separate path-token set (not the BM25 index), so
|
|
328
|
+
// length normalisation never penalises long implementations sharing a path.
|
|
329
|
+
const canBoost = !boostEligible || boostEligible.has(id);
|
|
330
|
+
if (canBoost && _queryPathTokens.length > 0) {
|
|
331
|
+
const pathToks = getPathTokens(id);
|
|
332
|
+
if (pathToks) {
|
|
333
|
+
const hasExact = _queryPathTokens.some(t => pathToks.has(t));
|
|
334
|
+
const hasPrefix = !hasExact && _queryPathTokens.some(t =>
|
|
335
|
+
t.length >= 4 && Array.from(pathToks).some(pt => pt.startsWith(t.slice(0, 5)))
|
|
336
|
+
);
|
|
337
|
+
if (hasExact) baseScore *= 1.4;
|
|
338
|
+
else if (hasPrefix) baseScore *= 1.2;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Name boost: 2.0× exact (token IS the name), 1.4× snake_case suffix match.
|
|
343
|
+
// Only [._] splitting keeps camelCase names atomic (no generic-suffix bleed).
|
|
344
|
+
// Plural equivalence (`BackgroundTask` ↔ `BackgroundTasks`) is included —
|
|
345
|
+
// API names pluralize and a strict equality check missed them.
|
|
346
|
+
if (canBoost && chunk.name && chunk.name !== 'anonymous') {
|
|
347
|
+
const nameLower = chunk.name.toLowerCase();
|
|
348
|
+
const lastDotted = nameLower.split('.').pop() ?? nameLower;
|
|
349
|
+
const queryTokensAll = _queryNameTokens;
|
|
350
|
+
const eq = (a, b) => a === b || a === b + 's' || b === a + 's';
|
|
351
|
+
if (queryTokensAll.some(t => eq(nameLower, t) || eq(lastDotted, t))) {
|
|
352
|
+
// NOTE: a PageRank multiplier was trialled here (tie-break duplicate
|
|
353
|
+
// names toward central files) and measured NEGATIVE — hub files win
|
|
354
|
+
// exact-name matches on common words and the semantic channel drops.
|
|
355
|
+
baseScore *= 2.0;
|
|
356
|
+
} else {
|
|
357
|
+
const snakeParts = nameLower.split(/[._]+/);
|
|
358
|
+
const lastSnake = snakeParts[snakeParts.length - 1] ?? '';
|
|
359
|
+
if (snakeParts.length >= 2 && lastSnake.length >= 3 && queryTokensAll.includes(lastSnake)) {
|
|
360
|
+
baseScore *= 1.4;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
rrfScores.set(id, (rrfScores.get(id) || 0) + baseScore);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Optional guaranteed boost for an exactly-named symbol (search_code exact_tokens).
|
|
369
|
+
if (exactBoostName && resolveExact) {
|
|
370
|
+
const boostTerm = String(exactBoostName).toLowerCase().trim();
|
|
371
|
+
for (const id of resolveExact(boostTerm)) {
|
|
372
|
+
rrfScores.set(id, (rrfScores.get(id) || 0) + 1 / (K + 1));
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
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
|
+
return Array.from(rrfScores.entries())
|
|
379
|
+
.sort((a, b) => (b[1] - a[1]) || (a[0] < b[0] ? -1 : 1))
|
|
380
|
+
.slice(0, topK)
|
|
381
|
+
.map(([id, rrfScore]) => ({ score: rrfScore, chunk: getChunk(id) }))
|
|
382
|
+
.filter(r => r.chunk !== undefined);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// ─── Graph centrality ──────────────────────────────────────────────────────────
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Simplified PageRank over the file dependency graph. Files imported by many
|
|
389
|
+
* others receive higher rank (= more important). Used by get_repo_map ordering
|
|
390
|
+
* and by LLM enrichment to pick the "core" files worth summarising.
|
|
391
|
+
*
|
|
392
|
+
* @param {{dependencies:Object<string,string[]>}} graph
|
|
393
|
+
* @returns {Map<string, number>} file → rank
|
|
394
|
+
*/
|
|
395
|
+
export function computePageRank(graph, iters = 30, damping = 0.85) {
|
|
396
|
+
const files = Object.keys(graph?.dependencies || {});
|
|
397
|
+
const N = files.length;
|
|
398
|
+
if (N === 0) return new Map();
|
|
399
|
+
|
|
400
|
+
const idx = new Map(files.map((f, i) => [f, i]));
|
|
401
|
+
const ranks = new Float64Array(N).fill(1.0 / N);
|
|
402
|
+
const outDeg = files.map(f => Math.max((graph.dependencies[f] || []).length, 1));
|
|
403
|
+
|
|
404
|
+
for (let iter = 0; iter < iters; iter++) {
|
|
405
|
+
const next = new Float64Array(N).fill((1 - damping) / N);
|
|
406
|
+
for (let i = 0; i < N; i++) {
|
|
407
|
+
const contrib = damping * ranks[i] / outDeg[i];
|
|
408
|
+
for (const dep of (graph.dependencies[files[i]] || [])) {
|
|
409
|
+
const j = idx.get(dep);
|
|
410
|
+
if (j !== undefined) next[j] += contrib;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
ranks.set(next);
|
|
414
|
+
}
|
|
415
|
+
return new Map(files.map((f, i) => [f, ranks[i]]));
|
|
416
|
+
}
|