pi-context-engine 0.1.4 → 0.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.
@@ -0,0 +1,112 @@
1
+ /**
2
+ * R10: opt-in local cross-encoder reranker (plan: docs/IMPLEMENTATION-PLAN.md).
3
+ *
4
+ * Why opt-in and OFF by default: reranking evidence is split — a core gain in
5
+ * CodeRAG-style pipelines, but measured harmful for SE tasks when it costs
6
+ * ~800 ms per query. Only this package's own eval can decide, and the model
7
+ * download (~80 MB) must never happen implicitly in a package that markets
8
+ * "one npm command".
9
+ *
10
+ * The reranker reorders the fused candidate list (capped); path penalty, file
11
+ * diversity and the marginal stop are applied AFTER it, unchanged. If the
12
+ * model cannot load, retrieval silently falls back to the fused order — a
13
+ * missing optional download degrades, never breaks.
14
+ *
15
+ * Implementation notes (PR #7 review): we do NOT go through the
16
+ * text-classification pipeline. Two reasons:
17
+ *
18
+ * 1. Its input is a plain string — there is no `{text, text_pair}` shape, so
19
+ * pair scoring via the pipeline throws and permanently disables reranking.
20
+ * 2. Cross-encoder rerankers (ms-marco-MiniLM) emit ONE logit; the pipeline
21
+ * softmaxes it, so every document scores exactly 1.0 and no reordering
22
+ * can happen. Sentence-transformers' CrossEncoder applies a sigmoid to
23
+ * the raw logit instead — we do the same, on raw model logits.
24
+ */
25
+
26
+ import { transformersCacheDir } from "../indexer/embedder.js";
27
+
28
+ /** Minimal interface so unit tests can inject a stub (no model download). */
29
+ export interface RerankerLike {
30
+ rerank(query: string, docs: string[]): Promise<number[]>;
31
+ }
32
+
33
+ /** Structural slice of @huggingface/transformers we need (kept `any`-light). */
34
+ interface TransformersModule {
35
+ AutoTokenizer: {
36
+ from_pretrained: (model: string, options?: Record<string, unknown>) => Promise<TokenizerLike>;
37
+ };
38
+ AutoModelForSequenceClassification: {
39
+ from_pretrained: (model: string, options?: Record<string, unknown>) => Promise<ModelLike>;
40
+ };
41
+ env: Record<string, unknown>;
42
+ }
43
+
44
+ interface TokenizerLike {
45
+ (text: string, options: Record<string, unknown>): unknown;
46
+ }
47
+
48
+ interface ModelLike {
49
+ (inputs: unknown): Promise<{ logits: { data: Float32Array | number[] } }>;
50
+ }
51
+
52
+ type LoadedReranker = { tokenizer: TokenizerLike; model: ModelLike };
53
+
54
+ export class LocalReranker implements RerankerLike {
55
+ private loaded: LoadedReranker | null = null;
56
+ private loading: Promise<LoadedReranker | null> | null = null;
57
+ /** Set after a load failure; every later call short-circuits to no-op. */
58
+ failed = false;
59
+
60
+ constructor(
61
+ readonly model: string,
62
+ private readonly loadTransformers: () => Promise<TransformersModule> = async () =>
63
+ (await import("@huggingface/transformers")) as unknown as TransformersModule,
64
+ ) {}
65
+
66
+ async rerank(query: string, docs: string[]): Promise<number[]> {
67
+ if (this.failed || docs.length === 0) return [];
68
+ try {
69
+ const loaded = await this.ensureLoaded();
70
+ if (!loaded) return [];
71
+ const { tokenizer, model } = loaded;
72
+ const scores: number[] = [];
73
+ // One pair per call: 30 sequential CPU inferences are well inside
74
+ // the latency budget this opt-in feature accepts, and per-pair
75
+ // truncation keeps a long doc from padding the whole batch.
76
+ for (const doc of docs) {
77
+ const inputs = tokenizer(query, { text_pair: doc, padding: true, truncation: true });
78
+ const out = await model(inputs);
79
+ const logit = Number(out.logits.data[0] ?? 0);
80
+ // Sigmoid, not softmax: single-logit cross-encoders must keep
81
+ // their ranking signal (softmax over one logit is constant 1).
82
+ scores.push(1 / (1 + Math.exp(-logit)));
83
+ }
84
+ return scores;
85
+ } catch {
86
+ this.failed = true;
87
+ return [];
88
+ }
89
+ }
90
+
91
+ private ensureLoaded(): Promise<LoadedReranker | null> {
92
+ if (this.loaded) return Promise.resolve(this.loaded);
93
+ if (!this.loading) {
94
+ this.loading = (async () => {
95
+ const mod = await this.loadTransformers();
96
+ // Same models dir as the embedder, so opt-in reranking never
97
+ // downloads into a stray ./Xenova folder inside the project.
98
+ mod.env.cacheDir = transformersCacheDir();
99
+ const tokenizer = await mod.AutoTokenizer.from_pretrained(this.model);
100
+ const model = await mod.AutoModelForSequenceClassification.from_pretrained(this.model, {
101
+ quantized: true,
102
+ });
103
+ this.loaded = { tokenizer, model };
104
+ return this.loaded;
105
+ })().catch(() => {
106
+ this.failed = true;
107
+ return null;
108
+ });
109
+ }
110
+ return this.loading;
111
+ }
112
+ }
@@ -5,8 +5,10 @@
5
5
  import type { Chunk } from "../types.js";
6
6
  import type { LocalBackend } from "../storage/local-backend.js";
7
7
  import type { Embedder } from "../indexer/embedder.js";
8
+ import { parseContextHeaderUnit } from "../indexer/context-header.js";
8
9
  import { ConfidenceScorer } from "./confidence.js";
9
- import { QueryParser, type ParsedQuery } from "./query-parser.js";
10
+ import { elaborateQuery, QueryParser, type ParsedQuery } from "./query-parser.js";
11
+ import type { RerankerLike } from "./reranker.js";
10
12
 
11
13
  const DEPRIORITISED_PATHS = ["tests/", "test_", "docs/", "spec", "plan"];
12
14
  const RRF_K = 60;
@@ -14,13 +16,52 @@ const CONFIDENCE_WEIGHT = 0.5;
14
16
  const MAX_CHUNKS_PER_FILE = 3;
15
17
  const FTS_BOOST_CODE_LOOKUP = 1.5;
16
18
  const WORST_COSINE_DISTANCE = 2.0;
19
+ /** R2: reweight near-ties toward the chunk that defines an exact query symbol. */
20
+ const SYMBOL_MATCH_BONUS = 0.06;
21
+ /** R5: structured paths — symbol nodes and explicit file hints as extra RRF lists. */
22
+ const SYMBOL_LIST_MAX = 20;
23
+ const HINT_FILES_MAX = 2;
24
+ const HINT_CHUNKS_PER_FILE = 10;
25
+ const HINT_PATH_BOOST = 1.5;
26
+ /** R6: small-to-big serving — parent module + adjacent chunk for top hits. */
27
+ const EXPAND_FILES_MAX = 3;
28
+ const EXPAND_NEIGHBOR_GAP = 30;
29
+ const EXPAND_SIBLINGS_LOOKUP = 16;
30
+ /** R7: max multiplicative boost from the file PageRank prior (hub files). */
31
+ const PAGE_RANK_WEIGHT = 0.1;
32
+ /** R8: auto-elaborated candidates fuse at half weight and never outscore a raw hit. */
33
+ const ELABORATE_WEIGHT = 0.5;
34
+ /** R10: opt-in reranker reorders at most this many fused candidates. */
35
+ const RERANK_TOPN = 30;
36
+ /** R10: blend weight of the reranker score vs the fused score. */
37
+ const RERANK_WEIGHT = 0.7;
38
+
39
+ /** R8: deterministic query-quality signals exposed to the engine/tool layer. */
40
+ export interface QuerySignals {
41
+ /** Best final score after fusion (0..1). */
42
+ topScore: number;
43
+ /** Score drop from the top hit to the best hit from a DIFFERENT file. */
44
+ margin: number;
45
+ /** Jaccard overlap of vector-top-10 and FTS-top-10 ids (0..1). */
46
+ agreement: number;
47
+ /** Distinct candidates that reached fusion. */
48
+ candidates: number;
49
+ }
17
50
 
18
51
  export interface RetrieveOptions {
19
52
  topK?: number;
20
53
  confidenceThreshold?: number;
21
54
  maxTokens?: number | null;
22
55
  marginalRatio?: number;
23
- statsOut?: { candidates?: number; selected?: number; droppedLowValue?: number };
56
+ statsOut?: {
57
+ candidates?: number;
58
+ selected?: number;
59
+ droppedLowValue?: number;
60
+ /** R8: filled on every call; consumed by the confidence line in results. */
61
+ querySignals?: QuerySignals;
62
+ /** R9: files ranked 11-15 but not served — refine-hint material. */
63
+ nearMissFiles?: string[];
64
+ };
24
65
  }
25
66
 
26
67
  export class HybridRetriever {
@@ -28,10 +69,20 @@ export class HybridRetriever {
28
69
  private embedder: Embedder;
29
70
  private scorer = new ConfidenceScorer();
30
71
  private parser = new QueryParser();
72
+ private expandContext: boolean;
73
+ private autoElaborate: boolean;
74
+ private reranker: RerankerLike | null;
31
75
 
32
- constructor(backend: LocalBackend, embedder: Embedder) {
76
+ constructor(
77
+ backend: LocalBackend,
78
+ embedder: Embedder,
79
+ options: { expandContext?: boolean; autoElaborate?: boolean; reranker?: RerankerLike | null } = {},
80
+ ) {
33
81
  this.backend = backend;
34
82
  this.embedder = embedder;
83
+ this.expandContext = options.expandContext ?? true;
84
+ this.autoElaborate = options.autoElaborate ?? false;
85
+ this.reranker = options.reranker ?? null;
35
86
  }
36
87
 
37
88
  async retrieve(query: string, options: RetrieveOptions = {}): Promise<Chunk[]> {
@@ -66,8 +117,77 @@ export class HybridRetriever {
66
117
  }
67
118
  }
68
119
 
120
+ // ── R5: structured paths ───────────────────────────────────────────────
121
+ // Two deterministic lists fused alongside vector and BM25: exact symbol
122
+ // hits from the graph index (so an identifier query hits the definition,
123
+ // not prose about it) and the chunks of files the query names outright.
124
+ // Evidence: CodeRAG's multi-path retrieval is a core reason it beats
125
+ // single-similarity baselines on repo-level tasks.
126
+ const symbolRanks = new Map<string, number>();
127
+ const hintRanks = new Map<string, number>();
128
+ if (parsed.keywords.length > 0) {
129
+ this.backend.findSymbols(parsed.keywords, SYMBOL_LIST_MAX).forEach((node, i) => {
130
+ symbolRanks.set(node.id, i);
131
+ });
132
+ }
133
+ if (parsed.fileHints.length > 0) {
134
+ const files = Object.keys(this.backend.fileChunkCounts());
135
+ let rank = 0;
136
+ for (const hint of parsed.fileHints.slice(0, 3)) {
137
+ const matches = files.filter((f) => f.includes(hint)).sort().slice(0, HINT_FILES_MAX);
138
+ for (const file of matches) {
139
+ for (const chunk of this.backend.chunksByFile(file, HINT_CHUNKS_PER_FILE)) {
140
+ if (!hintRanks.has(chunk.id)) hintRanks.set(chunk.id, rank++);
141
+ }
142
+ }
143
+ }
144
+ }
145
+ const structuredOnlyIds = [...symbolRanks.keys(), ...hintRanks.keys()].filter((id) => !chunkMap.has(id));
146
+ if (structuredOnlyIds.length > 0) {
147
+ for (const chunk of this.backend.getChunksByIds(structuredOnlyIds)) chunkMap.set(chunk.id, chunk);
148
+ }
149
+
150
+ // ── R8: optional deterministic elaboration ────────────────────────────
151
+ // A stopword-stripped variant enriched with terms from near-miss FTS
152
+ // hits, searched AGAIN and fused as an extra candidate source at half
153
+ // weight. Only ids the raw lists missed are eligible, so every raw
154
+ // candidate's score (and their order among themselves) is untouched —
155
+ // elaboration can only add, never demote. Default off; the eval runs
156
+ // with it off so the gate measures the un-elaborated baseline.
157
+ const elaboratedRanks = new Map<string, number>();
158
+ if (this.autoElaborate && parsed.keywords.length > 0) {
159
+ try {
160
+ const nearMiss = this.backend.getChunksByIds([...ftsIds.keys()].slice(0, 5));
161
+ const variant = elaborateQuery(query, nearMiss);
162
+ if (variant) {
163
+ const variantEmbedding = await this.embedder.embedQuery(variant);
164
+ const seenRaw = (id: string): boolean => vectorRanks.has(id) || ftsIds.has(id);
165
+ this.backend.vectorSearch(variantEmbedding, topK * 3).forEach((c, rank) => {
166
+ if (!seenRaw(c.id) && !elaboratedRanks.has(c.id)) elaboratedRanks.set(c.id, rank);
167
+ });
168
+ this.backend.ftsSearch(variant, topK * 3).forEach((r, i) => {
169
+ if (!seenRaw(r.id) && !elaboratedRanks.has(r.id)) elaboratedRanks.set(r.id, i);
170
+ });
171
+ const elabOnlyIds = [...elaboratedRanks.keys()].filter((id) => !chunkMap.has(id));
172
+ if (elabOnlyIds.length > 0) {
173
+ for (const chunk of this.backend.getChunksByIds(elabOnlyIds)) chunkMap.set(chunk.id, chunk);
174
+ }
175
+ }
176
+ } catch {
177
+ // elaboration is best-effort
178
+ }
179
+ }
180
+
69
181
  const ftsWeight = parsed.intent === "code_lookup" ? FTS_BOOST_CODE_LOOKUP : 1.0;
70
- const allIds = new Set([...vectorRanks.keys(), ...ftsIds.keys()]);
182
+ const symbolWeight = parsed.intent === "code_lookup" ? FTS_BOOST_CODE_LOOKUP : 1.0;
183
+ const pageRanks = this.backend.pageRanks();
184
+ const allIds = new Set([
185
+ ...vectorRanks.keys(),
186
+ ...ftsIds.keys(),
187
+ ...symbolRanks.keys(),
188
+ ...hintRanks.keys(),
189
+ ...elaboratedRanks.keys(),
190
+ ]);
71
191
  const rrfScores = new Map<string, number>();
72
192
  for (const id of allIds) {
73
193
  let score = 0;
@@ -75,6 +195,12 @@ export class HybridRetriever {
75
195
  if (vRank !== undefined) score += 1 / (RRF_K + vRank);
76
196
  const fRank = ftsIds.get(id);
77
197
  if (fRank !== undefined) score += ftsWeight * (1 / (RRF_K + fRank));
198
+ const sRank = symbolRanks.get(id);
199
+ if (sRank !== undefined) score += symbolWeight * (1 / (RRF_K + sRank));
200
+ const hRank = hintRanks.get(id);
201
+ if (hRank !== undefined) score += HINT_PATH_BOOST * (1 / (RRF_K + hRank));
202
+ const eRank = elaboratedRanks.get(id);
203
+ if (eRank !== undefined) score += ELABORATE_WEIGHT * (1 / (RRF_K + eRank));
78
204
  rrfScores.set(id, score);
79
205
  }
80
206
  const maxRrf = Math.max(0, ...rrfScores.values());
@@ -95,12 +221,55 @@ export class HybridRetriever {
95
221
  const confScore = this.scorer.score(chunk, normalisedDistance, keywordDistance);
96
222
  const normalisedRrf = maxRrf > 0 ? rrfScore / maxRrf : 0;
97
223
  let finalScore = CONFIDENCE_WEIGHT * confScore + (1 - CONFIDENCE_WEIGHT) * normalisedRrf;
224
+ finalScore += symbolBonus(chunk, parsed);
225
+ // R7: bounded PageRank prior. Structured-importance signal (CodeRank:
226
+ // PageRank over the code graph lifts entry points and hubs above
227
+ // equally-matching leaves). Multiplicative and capped so it can only
228
+ // break ties between near-equal candidates, never override evidence.
229
+ const prior = pageRanks.get(chunk.filePath) ?? 0;
230
+ if (prior > 0) finalScore *= 1 + PAGE_RANK_WEIGHT * prior;
98
231
  finalScore = applyPathPenalty(chunk.filePath, finalScore);
99
232
  chunk.confidenceScore = finalScore;
100
233
  scored.push({ chunk, score: finalScore });
101
234
  }
102
235
 
103
236
  scored.sort((a, b) => b.score - a.score);
237
+
238
+ // ── R10: opt-in rerank of the fused list ─────────────────────────────
239
+ // Reorders the top candidates only; penalties, dedupe, diversity and
240
+ // the marginal stop below are untouched. A failed/missing model leaves
241
+ // the fused order in place (rerank() returns []).
242
+ if (this.reranker && scored.length > 1) {
243
+ const topN = scored.slice(0, RERANK_TOPN);
244
+ const docs = topN.map((s) => s.chunk.compressedContent || s.chunk.content);
245
+ let rerankScores: number[] = [];
246
+ try {
247
+ rerankScores = await this.reranker.rerank(query, docs);
248
+ } catch {
249
+ // reranking is best-effort: model missing/offline → fused order stands
250
+ }
251
+ if (rerankScores.length === topN.length) {
252
+ const min = Math.min(...rerankScores);
253
+ const max = Math.max(...rerankScores);
254
+ const maxOld = topN[0]!.score || 1;
255
+ if (max > min) {
256
+ for (let i = 0; i < topN.length; i++) {
257
+ const norm = (rerankScores[i]! - min) / (max - min);
258
+ const blended = RERANK_WEIGHT * norm + (1 - RERANK_WEIGHT) * (topN[i]!.score / maxOld);
259
+ topN[i]!.score = blended;
260
+ topN[i]!.chunk.confidenceScore = blended;
261
+ }
262
+ // PR #7 review: a reranked candidate can blend down to ~0.3 while
263
+ // unreranked rank-31+ entries keep their raw ~0.7 scores — a full
264
+ // sort would promote unjudged tail hits OVER judged ones. Cap the
265
+ // tail strictly below the blended floor so reranking can only
266
+ // reorder within the judged set.
267
+ capTailBelowRerankedFloor(scored, RERANK_TOPN);
268
+ scored.sort((a, b) => b.score - a.score);
269
+ }
270
+ }
271
+ }
272
+
104
273
  const deduped = dedupeOverlaps(scored);
105
274
  const candidates = deduped.length;
106
275
 
@@ -108,22 +277,81 @@ export class HybridRetriever {
108
277
  if (filtered.length === 0 && deduped.length > 0) filtered = deduped.slice(0, 1);
109
278
  const droppedLowValue = candidates - filtered.length;
110
279
 
111
- // File diversity + marginal stop.
112
- const topScore = filtered.length > 0 ? filtered[0]!.score : 0;
280
+ // ── R8: query-quality signals ────────────────────────────────────────
281
+ // Deterministic inputs for the confidence line: absolute top score,
282
+ // the margin to the best hit from a different file (single-file results
283
+ // are fragile), vector/FTS agreement (two independent retrievers
284
+ // landing on the same chunks is the strongest quality signal), and the
285
+ // candidate count floor (tiny candidate pools are unreliable).
286
+ const firstHit = filtered[0]?.chunk;
287
+ let nextFileScore = -1;
288
+ if (firstHit) {
289
+ for (const s of filtered) {
290
+ if (s.chunk.filePath !== firstHit.filePath) {
291
+ nextFileScore = s.score;
292
+ break;
293
+ }
294
+ }
295
+ }
296
+ const vTop = new Set([...vectorRanks.keys()].slice(0, 10));
297
+ const fTop = new Set([...ftsIds.keys()].slice(0, 10));
298
+ let agreeInter = 0;
299
+ for (const id of vTop) if (fTop.has(id)) agreeInter += 1;
300
+ const agreeUnion = new Set([...vTop, ...fTop]).size;
301
+ const querySignals: QuerySignals = {
302
+ topScore: filtered[0]?.score ?? 0,
303
+ margin: firstHit ? (nextFileScore >= 0 ? filtered[0]!.score - nextFileScore : filtered[0]!.score) : 0,
304
+ agreement: agreeUnion > 0 ? agreeInter / agreeUnion : 0,
305
+ candidates: allIds.size,
306
+ };
307
+
308
+ // File diversity + marginal stop, with R6 small-to-big expansion: the
309
+ // first hit from a file pulls in its module parent and the closest
310
+ // neighbouring chunk so the agent gets surrounding context in ONE call
311
+ // instead of a follow-up expand_chunk (cross-file context length is the
312
+ // dominant quality factor in the controlled chunking study). Extras obey
313
+ // the same per-file cap: they ride along with an accepted hit, they never
314
+ // displace other files.
315
+ // Compare against a REPRESENTATIVE top score (3rd best), not the single
316
+ // best: one outlier at 0.9 used to push everything from rank ~5 onward
317
+ // below 0.75×top and cut true hits that raw ranking put at 5-15 (seen on
318
+ // five eval queries). The stop exists to shed junk tails, not to trust a
319
+ // lucky first candidate.
320
+ const topScore = filtered.length > 0 ? filtered[Math.min(2, filtered.length - 1)]!.score : 0;
113
321
  const fileCounts = new Map<string, number>();
114
322
  const diverse: Chunk[] = [];
323
+ const diverseKeys = new Set<string>();
324
+ const expandedFiles = new Set<string>();
115
325
  let marginalDropped = 0;
326
+ const push = (chunk: Chunk): boolean => {
327
+ const key = `${chunk.filePath}:${chunk.startLine}-${chunk.endLine}`;
328
+ if (diverseKeys.has(key)) return false;
329
+ const count = fileCounts.get(chunk.filePath) ?? 0;
330
+ if (count >= MAX_CHUNKS_PER_FILE) return false;
331
+ diverseKeys.add(key);
332
+ diverse.push(chunk);
333
+ fileCounts.set(chunk.filePath, count + 1);
334
+ return true;
335
+ };
336
+
116
337
  for (let idx = 0; idx < filtered.length; idx++) {
117
338
  const { chunk, score } = filtered[idx]!;
118
339
  if (diverse.length > 0 && marginalRatio > 0 && score < marginalRatio * topScore) {
119
340
  marginalDropped = filtered.length - idx;
120
341
  break;
121
342
  }
122
- const count = fileCounts.get(chunk.filePath) ?? 0;
123
- if (count < MAX_CHUNKS_PER_FILE) {
124
- diverse.push(chunk);
125
- fileCounts.set(chunk.filePath, count + 1);
126
- if (diverse.length >= topK) break;
343
+ if (diverse.length >= topK) break;
344
+ if (!push(chunk)) continue;
345
+ if (
346
+ this.expandContext &&
347
+ !expandedFiles.has(chunk.filePath) &&
348
+ expandedFiles.size < EXPAND_FILES_MAX
349
+ ) {
350
+ expandedFiles.add(chunk.filePath);
351
+ for (const extra of this.expansionChunks(chunk)) {
352
+ if (diverse.length >= topK) break;
353
+ push(extra);
354
+ }
127
355
  }
128
356
  }
129
357
  let ranked = diverse;
@@ -149,7 +377,21 @@ export class HybridRetriever {
149
377
  }
150
378
  }
151
379
 
152
- const stats = { candidates, selected: 0, droppedLowValue: droppedLowValue + marginalDropped };
380
+ // R9: near-miss files for refine hints — ranked 11-15 after dedupe but
381
+ // not served. Surfaced instead of silently vanishing so the tool result
382
+ // can point the agent one step deeper at near-zero token cost.
383
+ const servedFiles = new Set(diverse.map((c) => c.filePath));
384
+ const nearMissFiles = [...new Set(filtered.slice(10, 15).map((s) => s.chunk.filePath))]
385
+ .filter((f) => !servedFiles.has(f))
386
+ .slice(0, 2);
387
+
388
+ const stats = {
389
+ candidates,
390
+ selected: 0,
391
+ droppedLowValue: droppedLowValue + marginalDropped,
392
+ querySignals,
393
+ nearMissFiles,
394
+ };
153
395
  if (maxTokens === null) {
154
396
  stats.selected = ranked.length;
155
397
  if (options.statsOut) Object.assign(options.statsOut, stats);
@@ -175,6 +417,40 @@ export class HybridRetriever {
175
417
  if (options.statsOut) Object.assign(options.statsOut, stats);
176
418
  return packed;
177
419
  }
420
+ /**
421
+ * R6: parent context for an accepted hit — the file's module chunk plus the
422
+ * closest function/class chunk within a 30-line gap. Looked up from storage
423
+ * (cheap, ≤16 rows) so it works for hits that arrived via any path.
424
+ */
425
+ private expansionChunks(hit: Chunk): Chunk[] {
426
+ try {
427
+ const siblings = this.backend.chunksByFile(hit.filePath, EXPAND_SIBLINGS_LOOKUP);
428
+ const moduleChunk = siblings.find(
429
+ (c) => c.chunkType === "module" && !(c.startLine === hit.startLine && c.endLine === hit.endLine),
430
+ );
431
+ let neighbor: Chunk | undefined;
432
+ let bestGap = Number.POSITIVE_INFINITY;
433
+ for (const c of siblings) {
434
+ if (c.chunkType === "module" || c.id === hit.id) continue;
435
+ const gap =
436
+ c.startLine > hit.endLine
437
+ ? c.startLine - hit.endLine - 1
438
+ : hit.startLine > c.endLine
439
+ ? hit.startLine - c.endLine - 1
440
+ : -1;
441
+ if (gap >= 0 && gap <= EXPAND_NEIGHBOR_GAP && gap < bestGap) {
442
+ bestGap = gap;
443
+ neighbor = c;
444
+ }
445
+ }
446
+ const out: Chunk[] = [];
447
+ if (moduleChunk) out.push(moduleChunk);
448
+ if (neighbor) out.push(neighbor);
449
+ return out;
450
+ } catch {
451
+ return []; // expansion is best-effort
452
+ }
453
+ }
178
454
  }
179
455
 
180
456
  function chunkTokenCountFor(chunk: Chunk): number {
@@ -191,12 +467,33 @@ function applyPathPenalty(filePath: string, score: number): number {
191
467
  return score;
192
468
  }
193
469
 
470
+ /**
471
+ * PR #7 review fix: after blending rerank scores into the top-N, cap every
472
+ * unreranked tail entry strictly below the blended floor (monotonically
473
+ * decreasing, preserving the tail's own order). Without this, a full sort
474
+ * promotes unjudged rank-31+ hits over reranked candidates whose blend
475
+ * dropped low. Exported for unit testing.
476
+ */
477
+ export function capTailBelowRerankedFloor(scored: { score: number }[], topN: number): void {
478
+ if (scored.length <= topN) return;
479
+ let floor = Math.min(...scored.slice(0, topN).map((s) => s.score));
480
+ for (let i = topN; i < scored.length; i++) {
481
+ floor = Math.min(floor - 1e-6, scored[i]!.score);
482
+ scored[i]!.score = floor;
483
+ }
484
+ }
485
+
194
486
  function dedupeOverlaps(scored: { chunk: Chunk; score: number }[]): { chunk: Chunk; score: number }[] {
195
487
  const kept: { chunk: Chunk; score: number }[] = [];
196
488
  for (const { chunk, score } of scored) {
197
489
  let duplicate = false;
490
+ // Module chunks are the file's parent context: their span legitimately
491
+ // covers every function chunk, so overlap-based dedupe would delete the
492
+ // whole point of R6. Only leaf-vs-leaf overlaps are duplicates.
493
+ const isParent = chunk.chunkType === "module";
198
494
  for (const k of kept) {
199
495
  const kc = k.chunk;
496
+ if (isParent || kc.chunkType === "module") continue;
200
497
  if (kc.filePath !== chunk.filePath) continue;
201
498
  const overlap =
202
499
  Math.min(chunk.endLine, kc.endLine) - Math.max(chunk.startLine, kc.startLine) + 1;
@@ -212,6 +509,36 @@ function dedupeOverlaps(scored: { chunk: Chunk; score: number }[]): { chunk: Chu
212
509
  return kept;
213
510
  }
214
511
 
512
+ /**
513
+ * R2c: when a query names an exact symbol (`atomicWriteText`,
514
+ * `HybridRetriever`), the chunk whose unit *defines* that name should win
515
+ * near-ties against chunks that merely discuss it. High precision by design:
516
+ * whole-segment, case-insensitive equality only, small constant bonus, so it
517
+ * never outvotes real relevance evidence.
518
+ */
519
+ function symbolBonus(chunk: Chunk, parsed: ParsedQuery): number {
520
+ const unit =
521
+ typeof chunk.metadata["unit"] === "string"
522
+ ? (chunk.metadata["unit"] as string)
523
+ : parseContextHeaderUnit(chunk.content);
524
+ if (!unit) return 0;
525
+ const segments = unit.split(".").map((s) => s.toLowerCase());
526
+ for (const kw of parsed.keywords) {
527
+ const k = kw.toLowerCase();
528
+ if (k.length < 3) continue;
529
+ if (segments.includes(k)) return SYMBOL_MATCH_BONUS;
530
+ }
531
+ return 0;
532
+ }
533
+
534
+ /**
535
+ * Keyword evidence as COVERAGE, not any-hit. One generic word ("result",
536
+ * "list") used to earn the same full credit as naming the actual identifier,
537
+ * which let loosely-related chunks score like precise matches. Identifiers
538
+ * (camelCase / snake_case / acronyms) weigh 3x: they are rare and specific,
539
+ * which is exactly what code search rewards (see R2 evidence: BM25 and exact
540
+ * matching dominate code retrieval).
541
+ */
215
542
  function estimateKeywordDistance(chunk: Chunk, parsed: ParsedQuery): number {
216
543
  if (parsed.fileHints.length > 0) {
217
544
  for (const hint of parsed.fileHints) {
@@ -219,8 +546,18 @@ function estimateKeywordDistance(chunk: Chunk, parsed: ParsedQuery): number {
219
546
  }
220
547
  }
221
548
  const content = chunk.content.toLowerCase();
549
+ let total = 0;
550
+ let covered = 0;
222
551
  for (const keyword of parsed.keywords) {
223
- if (content.includes(keyword.toLowerCase())) return 0;
552
+ const weight = identifierLike(keyword) ? 3 : 1;
553
+ total += weight;
554
+ if (content.includes(keyword.toLowerCase())) covered += weight;
224
555
  }
225
- return 2;
556
+ if (total === 0) return 2;
557
+ // 0 = every keyword present, 2 = none present (the old miss value).
558
+ return (1 - covered / total) * 2;
559
+ }
560
+
561
+ function identifierLike(keyword: string): boolean {
562
+ return keyword.length >= 3 && (/[A-Z]/.test(keyword) || keyword.includes("_"));
226
563
  }
@@ -9,6 +9,17 @@ import type { Chunk } from "../types.js";
9
9
 
10
10
  const MAX_CONTENT_CHARS = 5_000;
11
11
 
12
+ /**
13
+ * BM25 column weights (table order: id, content, file_path, language,
14
+ * chunk_type). Weighting `file_path` above `content` makes filename stems
15
+ * in a query ("grammar rules" → `src/memory/grammar.ts`) decisive, while a
16
+ * single language/chunk-type token can never carry a match on its own.
17
+ * Evidence: BM25 is the strongest single retriever for code overall
18
+ * (arXiv:2605.14503 finding 6) and contextual BM25 was the bigger half of
19
+ * Anthropic's contextual-retrieval gain.
20
+ */
21
+ const BM25_WEIGHTS = "0.0, 1.0, 3.0, 0.5, 0.5";
22
+
12
23
  // Common English words dropped so natural-language phrasing doesn't drown the
13
24
  // signal terms. If every token is a stopword we fall back to using them all.
14
25
  const STOPWORDS = new Set([
@@ -19,16 +30,43 @@ const STOPWORDS = new Set([
19
30
  ]);
20
31
 
21
32
  /**
22
- * Build a safe FTS5 MATCH expression from free-form user input. Each token is
23
- * individually quoted (internal quotes doubled) which neutralises FTS5
24
- * operators; tokens are OR-joined so BM25 still ranks multi-token hits higher.
33
+ * Build a safe FTS5 MATCH expression from free-form user input.
34
+ *
35
+ * - Quoted spans become FTS5 phrases and are AND-constrained: `"error
36
+ * handling"` only matches when the words are adjacent, which is what a
37
+ * quoted string means to a user. Code symbols work because FTS5's
38
+ * tokenizer splits them the same way on both sides (`encode_token` →
39
+ * `encode token`).
40
+ * - Unquoted tokens are OR-joined (BM25 still ranks multi-token hits higher)
41
+ * so a natural-language query keeps matching whatever it can.
42
+ * - Stopwords are dropped; when every token is one, they are used as-is.
43
+ *
25
44
  * Returns "" when nothing usable remains — callers must skip the query.
26
45
  */
27
46
  export function escapeFts5(query: string): string {
28
- const tokens = query.split(/\s+/).filter((t) => /[a-zA-Z0-9]/.test(t));
47
+ const phrases: string[] = [];
48
+ const rest = query.replace(/"([^"]+)"/g, (_match, inner: string) => {
49
+ // Tokenize the phrase the way FTS5's tokenizer will have indexed it
50
+ // (unicode61: letters/digits are tokenchars, underscores and punctuation
51
+ // split — `encode_token` indexes as `encode token`). Unicode-aware so
52
+ // diacritics survive ("café" stays "café"; the tokenizer folds accents
53
+ // on BOTH sides, so ASCII-splitting them away made quoted phrases miss
54
+ // content the unquoted query could find).
55
+ const words = inner
56
+ .split(/[^\p{L}\p{N}]+/u)
57
+ .filter((w) => /[\p{L}\p{N}]/u.test(w));
58
+ if (words.length > 0) phrases.push(`"${words.join(" ")}"`);
59
+ return " ";
60
+ });
61
+
62
+ const tokens = rest.split(/\s+/).filter((t) => /[\p{L}\p{N}]/u.test(t));
29
63
  let kept = tokens.filter((t) => !STOPWORDS.has(t.toLowerCase()));
30
64
  if (kept.length === 0) kept = tokens;
31
- return kept.map((t) => `"${t.replace(/"/g, '""')}"`).join(" OR ");
65
+ const tokenExpr = kept.map((t) => `"${t.replace(/"/g, '""')}"`).join(" OR ");
66
+
67
+ if (phrases.length > 0 && tokenExpr) return `${phrases.join(" AND ")} AND (${tokenExpr})`;
68
+ if (phrases.length > 0) return phrases.join(" AND ");
69
+ return tokenExpr;
32
70
  }
33
71
 
34
72
  type Sqlite = SqliteHandle;
@@ -72,7 +110,11 @@ export class FTSStore {
72
110
  if (!matchExpr) return [];
73
111
  try {
74
112
  const rows = this.db
75
- .prepare("SELECT id, rank FROM chunks_fts WHERE chunks_fts MATCH ? ORDER BY rank LIMIT ?")
113
+ .prepare(
114
+ `SELECT id, bm25(chunks_fts, ${BM25_WEIGHTS}) AS rank
115
+ FROM chunks_fts WHERE chunks_fts MATCH ?
116
+ ORDER BY rank LIMIT ?`,
117
+ )
76
118
  .all(matchExpr, topK) as { id: string; rank: number }[];
77
119
  return rows;
78
120
  } catch {