Fix hybrid RRF weighting by query type

This commit is contained in:
Tobi Lütke 2026-05-09 17:52:42 +00:00
parent 92aaded36e
commit 004714af48
No known key found for this signature in database
3 changed files with 53 additions and 2 deletions

View File

@ -9,6 +9,7 @@
Scoped `--force` clears only collection-owned vectors, preserves shared
hashes referenced by sibling collections, and drops `vectors_vec` only
when the scoped clear empties all vectors.
- Hybrid search: weight RRF lists by query type so original FTS and original vector evidence get the intended 2x boost, instead of accidentally boosting the first lexical expansion. #591
- GPU: respect explicit `QMD_LLAMA_GPU=metal|vulkan|cuda` backend overrides instead of always using auto GPU selection. #529
- Fix: preserve original filename case in `handelize()`. The previous
`.toLowerCase()` call made indexed paths unreachable on case-sensitive

View File

@ -4158,6 +4158,21 @@ export type RankedListMeta = {
query: string;
};
/**
* RRF list weights for hybridQuery.
*
* Original-query retrieval paths are the primary evidence and get 2x weight:
* - original FTS
* - original vector search
*
* Expansion-derived lists (lex/vec/hyde) stay at 1x regardless of list order,
* so a lex expansion inserted before original vector search cannot steal the
* original vector boost.
*/
export function getHybridRrfWeights(rankedListMeta: RankedListMeta[]): number[] {
return rankedListMeta.map(meta => meta.queryType === "original" ? 2.0 : 1.0);
}
/**
* Hybrid search: BM25 + vector + query expansion + RRF + chunked reranking.
*
@ -4289,8 +4304,9 @@ export async function hybridQuery(
}
}
// Step 4: RRF fusion — first 2 lists (original FTS + first vec) get 2x weight
const weights = rankedLists.map((_, i) => i < 2 ? 2.0 : 1.0);
// Step 4: RRF fusion — original-query FTS and vector lists get 2x weight;
// expansion-derived lists stay at 1x independent of insertion order.
const weights = getHybridRrfWeights(rankedListMeta);
const fused = reciprocalRankFusion(rankedLists, weights);
const rrfTraceByFile = explain ? buildRrfTrace(rankedLists, weights, rankedListMeta) : null;
const candidates = fused.slice(0, candidateLimit);

View File

@ -52,10 +52,12 @@ import {
insertDocument,
generateEmbeddings,
reindexCollection,
getHybridRrfWeights,
type Store,
type DocumentResult,
type SearchResult,
type RankedResult,
type RankedListMeta,
} from "../src/store.js";
import type { CollectionConfig } from "../src/collections.js";
@ -2046,6 +2048,38 @@ describe("Reciprocal Rank Fusion", () => {
expect(fused[0]!.file).toBe("doc1");
});
test("hybrid RRF weights boost original vector evidence over expansion-only hits", () => {
const originalFtsOnly = makeResult("original-fts-only.md", 0.95);
const expansionOnly = makeResult("lex-expansion-only.md", 0.95);
const originalVector = makeResult("original-vector.md", 0.95);
// Mirrors hybridQuery's common list order when a lex expansion exists:
// original FTS, lex expansion FTS, original vector.
const rankedLists = [
[originalFtsOnly],
[expansionOnly],
[originalVector],
];
const rankedListMeta: RankedListMeta[] = [
{ source: "fts", queryType: "original", query: "user query" },
{ source: "fts", queryType: "lex", query: "lex expansion" },
{ source: "vec", queryType: "original", query: "user query" },
];
const positionBasedWeights = rankedLists.map((_, i) => i < 2 ? 2.0 : 1.0);
const buggyOrder = reciprocalRankFusion(rankedLists, positionBasedWeights);
expect(buggyOrder.findIndex(r => r.file === "lex-expansion-only.md"))
.toBeLessThan(buggyOrder.findIndex(r => r.file === "original-vector.md"));
const semanticWeights = getHybridRrfWeights(rankedListMeta);
const fixedOrder = reciprocalRankFusion(rankedLists, semanticWeights);
expect(semanticWeights).toEqual([2.0, 1.0, 2.0]);
expect(fixedOrder.findIndex(r => r.file === "original-vector.md"))
.toBeLessThan(fixedOrder.findIndex(r => r.file === "lex-expansion-only.md"));
});
test("RRF adds top-rank bonus", () => {
// doc1 is #1 in list1, doc2 is #2 in list1
const list1 = [makeResult("doc1", 0.9), makeResult("doc2", 0.8)];