import { describe, it, expect, beforeEach } from "vitest"; import { hybrid, clearIndexCache } from "../index.js"; /** * BM25 carve-out under the vector threshold. * * The carve-out preserves rows that entered via the BM25 path even when * their vector cosine is below the threshold. Justification: a literal * token match (BM25 hit) is the operator's strongest "yes I meant this" * signal — a mediocre embedding score should not silently suppress it. * * The implementation uses a `bm25Hit` boolean (not `bm25Score > 0`) * because min-max normalisation pins the lowest BM25 row's score to 0. * If the carve-out tested `bm25Score > 0` it would falsely drop the * weakest BM25 hit in any set with >1 BM25 results — even though that * row IS a literal match. This file pins both: * 1. a row with weak vector + strong BM25 survives (basic carve-out) * 2. the row with the WEAKEST BM25 in a multi-BM25 set survives even * after min-max normalisation collapses its bm25Score to 0 */ interface StubRun { match: (query: string) => boolean; records: Array>; } function record(fields: Record) { return { get: (k: string) => fields[k] }; } function makeStubSession(scripted: StubRun[]) { return { run(query: string) { const hit = scripted.find((s) => s.match(query)); if (!hit) return Promise.resolve({ records: [] }); return Promise.resolve({ records: hit.records.map(record) }); }, } as unknown as import("neo4j-driver").Session; } beforeEach(() => { clearIndexCache(); }); describe("vectorThreshold — bm25 carve-out", () => { it("preserves a weak-vector row when BM25 hit raw score is strong (mirrors savage case)", async () => { // Reflects the Real Agent regression: relevant Brochure has BM25 ≈ 0.74 // (single token match) and vector ≈ 0.65 (mediocre semantic match). // Threshold 0.7 would drop it on vector alone; carve-out keeps it. const session = makeStubSession([ { match: (q) => q.includes("SHOW INDEXES"), records: [{ name: "vec_project", labelsOrTypes: ["Project"] }], }, { match: (q) => q.includes("db.index.vector.queryNodes"), records: [ { nodeId: "brochure", nodeLabels: ["Project"], node: { properties: { name: "Brochure Production — John Savage" } }, score: 0.65 }, { nodeId: "noise-1", nodeLabels: ["Project"], node: { properties: {} }, score: 0.55 }, ], }, { match: (q) => q.includes("db.index.fulltext.queryNodes"), records: [ { nodeId: "brochure", nodeLabels: ["Project"], node: { properties: { name: "Brochure Production — John Savage" } }, score: 10 }, ], }, ]); const res = await hybrid(session, async () => [0.1], { query: "savage", accountId: "a", limit: 100, labels: ["Project"], expandHops: 0, vectorThreshold: 0.7, }); expect(res.results.map((r) => r.nodeId)).toEqual(["brochure"]); expect(res.bm25Bypass).toBe(1); expect(res.suppressed).toBe(1); // noise-1 dropped }); it("preserves the WEAKEST-BM25 row whose normalised score collapses to 0", async () => { // Two BM25 hits with raw scores [10, 5] → normalised [1.0, 0.0]. // Both have weak vector cosine (below threshold). Without the // bm25Hit flag, the row whose normalised score is exactly 0.0 would // get falsely suppressed by a `bm25Score > 0` check — even though // it IS a literal-token match. The flag is the orthogonal signal. const session = makeStubSession([ { match: (q) => q.includes("SHOW INDEXES"), records: [{ name: "vec_project", labelsOrTypes: ["Project"] }], }, { match: (q) => q.includes("db.index.vector.queryNodes"), records: [ { nodeId: "strong-bm25", nodeLabels: ["Project"], node: { properties: {} }, score: 0.4 }, { nodeId: "weak-bm25", nodeLabels: ["Project"], node: { properties: {} }, score: 0.3 }, ], }, { match: (q) => q.includes("db.index.fulltext.queryNodes"), records: [ { nodeId: "strong-bm25", nodeLabels: ["Project"], node: { properties: {} }, score: 10 }, { nodeId: "weak-bm25", nodeLabels: ["Project"], node: { properties: {} }, score: 5 }, ], }, ]); const res = await hybrid(session, async () => [0.1], { query: "anything", accountId: "a", limit: 100, labels: ["Project"], expandHops: 0, vectorThreshold: 0.7, }); const ids = res.results.map((r) => r.nodeId).sort(); expect(ids).toEqual(["strong-bm25", "weak-bm25"]); // Both kept by carve-out (both vector below threshold; both BM25 hits). expect(res.bm25Bypass).toBe(2); expect(res.suppressed).toBe(0); // Weakest BM25 row's normalised score IS 0.0 — pin the artefact so a // future "let's just check bm25Score > 0" refactor breaks the test. const weak = res.results.find((r) => r.nodeId === "weak-bm25"); expect(weak?.bm25Score).toBe(0); }); });