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.
- package/README.md +49 -0
- package/docs/IMPLEMENTATION-PLAN.md +30 -13
- package/package.json +83 -81
- package/src/compression/compressor.ts +119 -20
- package/src/config.ts +59 -3
- package/src/engine.ts +206 -12
- package/src/extension.ts +12 -1
- package/src/guidance.ts +1 -0
- package/src/indexer/chunker.ts +321 -21
- package/src/indexer/context-header.ts +86 -0
- package/src/indexer/embedder.ts +16 -1
- package/src/indexer/manifest.ts +8 -4
- package/src/indexer/pipeline.ts +223 -18
- package/src/memory/db.ts +128 -1
- package/src/retrieval/confidence.ts +53 -0
- package/src/retrieval/query-parser.ts +33 -0
- package/src/retrieval/reranker.ts +112 -0
- package/src/retrieval/retriever.ts +351 -14
- package/src/storage/fts-store.ts +48 -6
- package/src/storage/graph-store.ts +160 -4
- package/src/storage/local-backend.ts +36 -0
- package/src/storage/vector-store.ts +22 -0
|
@@ -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?: {
|
|
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(
|
|
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
|
|
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
|
-
//
|
|
112
|
-
|
|
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
|
-
|
|
123
|
-
if (
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/storage/fts-store.ts
CHANGED
|
@@ -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.
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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
|
|
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
|
-
|
|
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(
|
|
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 {
|