Skip to content

Commit 70fad59

Browse files
kfaracikclaude
andcommitted
fix(rag): keep vector top-N on paraphrase queries, not just the single best
The semantic top-keep floor only ever rescued the one highest-similarity candidate from the gate, so pure-paraphrase queries returned a single chunk where naive vector retrieval would surface its whole top-N. On-device eval (266 passages / 1433 queries / 6 languages) showed this as the hybrid retriever's only regression vs the pure-vector baseline. Retain the top SEMANTIC_TOP_KEEP_N (=5) candidates above the floor instead. Measured effect: semantic recall +3, Arabic +7.5, with no regression on any other query type or language. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent d616dd5 commit 70fad59

3 files changed

Lines changed: 53 additions & 11 deletions

File tree

__tests__/hybridRetrieval.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,47 @@ describe('hybridRetrieve', () => {
266266
expect(result.map((c) => c.metadata?.name)).toContain('Everest');
267267
});
268268

269+
it('keeps several top-similarity semantic chunks above the floor, not just the single best', async () => {
270+
mockKeywordSearch.mockResolvedValue([]);
271+
const result = await hybridRetrieve({
272+
prompt: 'how do mountains form',
273+
enabledSourceIds: [1],
274+
vectorStore: makeVectorStore(
275+
[
276+
{
277+
id: '1:0',
278+
document: 'first paraphrase-relevant passage',
279+
embedding: [1, 0],
280+
similarity: 0.33,
281+
metadata: { documentId: 1, name: 'A' },
282+
},
283+
{
284+
id: '2:0',
285+
document: 'second paraphrase-relevant passage',
286+
embedding: [0.9, 0.1],
287+
similarity: 0.3,
288+
metadata: { documentId: 2, name: 'B' },
289+
},
290+
{
291+
id: '3:0',
292+
document: 'third paraphrase-relevant passage',
293+
embedding: [0.8, 0.2],
294+
similarity: 0.28,
295+
metadata: { documentId: 3, name: 'C' },
296+
},
297+
],
298+
{}
299+
),
300+
sourceNamesById: new Map(),
301+
embeddings: null,
302+
});
303+
304+
const names = result.map((c) => c.metadata?.name);
305+
expect(names).toContain('A');
306+
expect(names).toContain('B');
307+
expect(names).toContain('C');
308+
});
309+
269310
it('keeps a mid-similarity chunk when the query shares terms with it', async () => {
270311
mockKeywordSearch.mockResolvedValue([]);
271312
const result = await hybridRetrieve({

constants/retrieval.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,12 @@ export const ADAPTIVE_K_MIN_KEEP = 1;
3232
/** Cosine floor to qualify on semantics alone. Measured on-device: LFM2.5 true paraphrase matches land ~0.28–0.54, so 0.40 admits clear matches while most unrelated passages stay below. */
3333
export const STRONG_SEMANTIC_THRESHOLD = 0.4;
3434

35-
/** The single highest-similarity candidate always qualifies above this floor, so the best semantic match is never fully gated out (empty result) on a paraphrase that clears neither the main threshold nor any keyword/coverage overlap. */
35+
/** The highest-similarity candidates always qualify above this floor, so paraphrase matches are never gated out when they clear neither the main threshold nor any keyword/coverage overlap. */
3636
export const SEMANTIC_TOP_KEEP_FLOOR = 0.25;
3737

38+
/** How many of the top-similarity candidates the floor keeps. Retains the vector top-N on pure-paraphrase queries, matching what naive vector retrieval would surface. */
39+
export const SEMANTIC_TOP_KEEP_N = 5;
40+
3841
/** Min cosine to qualify via lexical overlap (paired with non-zero term coverage). */
3942
export const LEXICAL_MATCH_MIN_SIMILARITY = 0.1;
4043

utils/hybridRetrieval.ts

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
MAX_CHUNKS_PER_FILE,
2323
MAX_RELEVANT_CHUNKS,
2424
SEMANTIC_TOP_KEEP_FLOOR,
25+
SEMANTIC_TOP_KEEP_N,
2526
STRONG_SEMANTIC_THRESHOLD,
2627
VECTOR_WEIGHT,
2728
} from '../constants/retrieval';
@@ -321,10 +322,12 @@ export const hybridRetrieve = async ({
321322
};
322323

323324
const candidates = [...byId.values()];
324-
const topSemantic = candidates.reduce<Candidate | null>(
325-
(best, candidate) =>
326-
candidate.similarity > (best?.similarity ?? -Infinity) ? candidate : best,
327-
null
325+
const topSemanticIds = new Set(
326+
candidates
327+
.filter((c) => c.similarity >= SEMANTIC_TOP_KEEP_FLOOR)
328+
.sort((a, b) => b.similarity - a.similarity)
329+
.slice(0, SEMANTIC_TOP_KEEP_N)
330+
.map((c) => c.id)
328331
);
329332

330333
const qualified = candidates.filter((candidate) => {
@@ -334,12 +337,7 @@ export const hybridRetrieve = async ({
334337
if (candidate.similarity >= LEXICAL_MATCH_MIN_SIMILARITY) return true;
335338
}
336339
if (candidate.similarity >= STRONG_SEMANTIC_THRESHOLD) return true;
337-
if (
338-
candidate === topSemantic &&
339-
candidate.similarity >= SEMANTIC_TOP_KEEP_FLOOR
340-
) {
341-
return true;
342-
}
340+
if (topSemanticIds.has(candidate.id)) return true;
343341
return (
344342
candidate.similarity >= LEXICAL_MATCH_MIN_SIMILARITY &&
345343
coverageOf(candidate) > 0

0 commit comments

Comments
 (0)