diff --git a/CHANGELOG.md b/CHANGELOG.md index 4931d92..54d84bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/store.ts b/src/store.ts index f5dd47a..5adafd9 100644 --- a/src/store.ts +++ b/src/store.ts @@ -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); diff --git a/test/store.test.ts b/test/store.test.ts index 2ed0b06..24b5a10 100644 --- a/test/store.test.ts +++ b/test/store.test.ts @@ -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)];