import { describe, it, expect, beforeEach } from "vitest"; import { hybrid, clearIndexCache } from "../index.js"; /** * Hybrid tests cover the two decisions that make this lib non-trivial: * 1. overlapping vector+BM25 nodeIds combine as 0.7*vec + 0.3*bm25_norm * 2. degradeOnEmbedFailure=true with a failing embed returns bm25-only * and annotates mode="bm25" * Deeper integration (index discovery, expand, keyword subscriptions) is * covered by the downstream MCP tool tests against a real Neo4j. */ interface StubRun { match: (query: string) => boolean; records: Array>; } function record(fields: Record) { return { get: (k: string) => fields[k] }; } function makeStubSession(scripted: StubRun[]) { const calls: Array<{ query: string; params: Record }> = []; const session = { run(query: string, params: Record) { calls.push({ query, params }); 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; return { session, calls }; } beforeEach(() => { clearIndexCache(); }); describe("hybrid — score combination", () => { it("combines overlapping nodeIds as 0.7*vec + 0.3*bm25_norm (expand skipped)", async () => { const session = makeStubSession([ { match: (q) => q.includes("SHOW INDEXES"), records: [{ name: "vec_knowledge", labelsOrTypes: ["KnowledgeDocument"] }], }, { match: (q) => q.includes("db.index.vector.queryNodes"), records: [ { nodeId: "n1", nodeLabels: ["KnowledgeDocument"], node: { properties: { title: "A" } }, score: 0.9, }, { nodeId: "n2", nodeLabels: ["KnowledgeDocument"], node: { properties: { title: "B" } }, score: 0.5, }, ], }, { match: (q) => q.includes("db.index.fulltext.queryNodes"), records: [ { nodeId: "n1", nodeLabels: ["KnowledgeDocument"], node: { properties: { title: "A" } }, score: 10, }, { nodeId: "n3", nodeLabels: ["KnowledgeDocument"], node: { properties: { title: "C" } }, score: 5, }, ], }, ]).session; const embed = async () => [0.1, 0.2, 0.3]; const res = await hybrid(session, embed, { query: "test", accountId: "acc-1", limit: 10, expandHops: 0, }); expect(res.mode).toBe("hybrid"); // bm25 normalised: [10,5] -> [1,0]; n1: 0.7*0.9 + 0.3*1 = 0.93 // n2 (vec only): 0.7*0.5 + 0.3*0 = 0.35 // n3 (bm25 only): 0.7*0 + 0.3*0 = 0 const n1 = res.results.find((r) => r.nodeId === "n1"); const n2 = res.results.find((r) => r.nodeId === "n2"); const n3 = res.results.find((r) => r.nodeId === "n3"); expect(n1?.score).toBeCloseTo(0.93); expect(n2?.score).toBeCloseTo(0.35); expect(n3?.score).toBeCloseTo(0); // Ranking: n1 > n2 > n3 expect(res.results.map((r) => r.nodeId)).toEqual(["n1", "n2", "n3"]); }); // operator-facing per-row component scores. Without these the // hybrid combine is opaque: a row scoring 0.35 cannot be distinguished // between "vector hit, bm25 absent" and "weak partial match in both". it("populates vectorScore + bm25Score on every result", async () => { const session = makeStubSession([ { match: (q) => q.includes("SHOW INDEXES"), records: [{ name: "vec_knowledge", labelsOrTypes: ["KnowledgeDocument"] }], }, { match: (q) => q.includes("db.index.vector.queryNodes"), records: [ { nodeId: "n1", nodeLabels: ["KnowledgeDocument"], node: { properties: { title: "A" } }, score: 0.9 }, { nodeId: "n2", nodeLabels: ["KnowledgeDocument"], node: { properties: { title: "B" } }, score: 0.5 }, ], }, { match: (q) => q.includes("db.index.fulltext.queryNodes"), records: [ { nodeId: "n1", nodeLabels: ["KnowledgeDocument"], node: { properties: { title: "A" } }, score: 10 }, { nodeId: "n3", nodeLabels: ["KnowledgeDocument"], node: { properties: { title: "C" } }, score: 5 }, ], }, ]).session; const res = await hybrid(session, async () => [0.1, 0.2, 0.3], { query: "test", accountId: "acc-1", limit: 10, expandHops: 0, }); const n1 = res.results.find((r) => r.nodeId === "n1"); const n2 = res.results.find((r) => r.nodeId === "n2"); const n3 = res.results.find((r) => r.nodeId === "n3"); // n1: hit by both. raw vector cosine 0.9; bm25 normalised top = 1.0. expect(n1?.vectorScore).toBeCloseTo(0.9); expect(n1?.bm25Score).toBeCloseTo(1.0); // n2: vector-only. bm25 absent → bm25Score = 0. expect(n2?.vectorScore).toBeCloseTo(0.5); expect(n2?.bm25Score).toBe(0); // n3: bm25-only. vectorScore = 0; bm25 normalised bottom = 0. expect(n3?.vectorScore).toBe(0); expect(n3?.bm25Score).toBe(0); }); }); describe("hybrid — embed degrade", () => { const failingEmbed = async () => { throw new Error("Ollama unreachable"); }; it("throws when degradeOnEmbedFailure is false (default)", async () => { const { session } = makeStubSession([ { match: () => true, records: [] }, ]); await expect( hybrid(session, failingEmbed, { query: "x", accountId: "a", limit: 5 }), ).rejects.toThrow("Ollama unreachable"); }); it("returns bm25-only result with mode='bm25' when degradeOnEmbedFailure is true", async () => { const { session } = makeStubSession([ { match: (q) => q.includes("db.index.fulltext.queryNodes"), records: [ { nodeId: "n1", nodeLabels: ["KnowledgeDocument"], node: { properties: { title: "hit" } }, score: 3, }, ], }, ]); const res = await hybrid(session, failingEmbed, { query: "x", accountId: "a", limit: 5, degradeOnEmbedFailure: true, }); expect(res.mode).toBe("bm25"); expect(res.embedError).toContain("Ollama unreachable"); expect(res.results).toHaveLength(1); expect(res.results[0]?.nodeId).toBe("n1"); // score is raw BM25 score (no hybrid combine in degrade path) expect(res.results[0]?.score).toBe(3); // related[] is [] in degrade path (no expand) expect(res.results[0]?.related).toEqual([]); // degraded path also populates components: vectorScore=0 // (no embed) and bm25Score is the normalised value (single hit → 1.0). expect(res.results[0]?.vectorScore).toBe(0); expect(res.results[0]?.bm25Score).toBe(1.0); }); // multi-hit degraded path: bm25Score is min-max normalised // across the local hit set, so the operator can still see "this row is // the strongest bm25 match (1.0) vs this row is the weakest (0.0)". it("normalises bm25Score across hits in degrade path", async () => { const { session } = makeStubSession([ { match: (q) => q.includes("db.index.fulltext.queryNodes"), records: [ { nodeId: "top", nodeLabels: ["KnowledgeDocument"], node: { properties: {} }, score: 10 }, { nodeId: "mid", nodeLabels: ["KnowledgeDocument"], node: { properties: {} }, score: 5 }, { nodeId: "low", nodeLabels: ["KnowledgeDocument"], node: { properties: {} }, score: 0 }, ], }, ]); const res = await hybrid(session, failingEmbed, { query: "x", accountId: "a", limit: 5, degradeOnEmbedFailure: true, }); expect(res.mode).toBe("bm25"); const byId = new Map(res.results.map((r) => [r.nodeId, r])); expect(byId.get("top")?.bm25Score).toBeCloseTo(1.0); expect(byId.get("mid")?.bm25Score).toBeCloseTo(0.5); expect(byId.get("low")?.bm25Score).toBeCloseTo(0.0); expect(byId.get("top")?.vectorScore).toBe(0); expect(byId.get("mid")?.vectorScore).toBe(0); expect(byId.get("low")?.vectorScore).toBe(0); }); it("returns empty results with mode='bm25' when embed fails and index is missing", async () => { const session = { run: (q: string) => { if (q.includes("db.index.fulltext.queryNodes")) { return Promise.reject(new Error("There is no such fulltext index")); } return Promise.resolve({ records: [] }); }, } as unknown as import("neo4j-driver").Session; const res = await hybrid(session, failingEmbed, { query: "x", accountId: "a", limit: 5, degradeOnEmbedFailure: true, }); expect(res.mode).toBe("bm25"); expect(res.results).toEqual([]); }); }); describe("hybrid — label filter without vector index (Task 478)", () => { // Pre-Task-478 the lib returned an empty result the moment the requested // labels had no vector index, hiding BM25 hits for labels like // :Organization that are covered by the universal fulltext index but lack // a per-label vector index. Post-fix the vector half is skipped while // BM25 still runs with the label filter. it("returns BM25 hits when label has no vector index but BM25 matches", async () => { const { session } = makeStubSession([ { match: (q) => q.includes("SHOW INDEXES"), records: [{ name: "vec_knowledge", labelsOrTypes: ["KnowledgeDocument"] }], }, { match: (q) => q.includes("db.index.fulltext.queryNodes"), records: [ { nodeId: "org-biosymm", nodeLabels: ["Organization"], node: { properties: { name: "BioSymm Technologies Ltd" } }, score: 7, }, ], }, ]); const embed = async () => [0.1]; const res = await hybrid(session, embed, { query: "BioSymm", accountId: "a", limit: 5, labels: ["Organization"], expandHops: 0, }); expect(res.results).toHaveLength(1); expect(res.results[0]?.nodeId).toBe("org-biosymm"); expect(res.results[0]?.labels).toContain("Organization"); // BM25-only contributor: vectorScore = 0, bm25Score normalised to 1.0 // (single hit set), combined = 0.3 * 1.0 = 0.3 under default weights. expect(res.results[0]?.vectorScore).toBe(0); expect(res.results[0]?.bm25Score).toBeCloseTo(1.0); expect(res.mode).toBe("hybrid"); }); it("returns empty honestly when label has no vector index and BM25 also misses", async () => { const { session } = makeStubSession([ { match: (q) => q.includes("SHOW INDEXES"), records: [{ name: "vec_knowledge", labelsOrTypes: ["KnowledgeDocument"] }], }, // No fulltext stub → BM25 returns []. ]); const embed = async () => [0.1]; const res = await hybrid(session, embed, { query: "x", accountId: "a", limit: 5, labels: ["UnknownLabel"], }); expect(res.results).toEqual([]); expect(res.mode).toBe("hybrid"); }); it("queries only the vector indexes that exist when labels mix indexed and non-indexed", async () => { const vectorCalls: string[] = []; const { session } = makeStubSession([ { match: (q) => q.includes("SHOW INDEXES"), records: [ { name: "person_embedding", labelsOrTypes: ["Person"] }, { name: "vec_knowledge", labelsOrTypes: ["KnowledgeDocument"] }, ], }, { match: (q) => q.includes("db.index.vector.queryNodes"), records: [ { nodeId: "p1", nodeLabels: ["Person"], node: { properties: { givenName: "Ricky" } }, score: 0.8 }, ], }, { match: (q) => q.includes("db.index.fulltext.queryNodes"), records: [ { nodeId: "org-1", nodeLabels: ["Organization"], node: { properties: { name: "BioSymm" } }, score: 5 }, { nodeId: "p1", nodeLabels: ["Person"], node: { properties: { givenName: "Ricky" } }, score: 4 }, ], }, ]); // Patch session.run to capture vector indexName params so we can assert // Organization (no index) is silently skipped while Person is queried. const origRun = session.run.bind(session); session.run = ((q: string, params: Record) => { if (q.includes("db.index.vector.queryNodes")) { vectorCalls.push(params.indexName as string); } return origRun(q, params); }) as typeof session.run; const res = await hybrid(session, async () => [0.1], { query: "BioSymm", accountId: "a", limit: 10, labels: ["Person", "Organization"], expandHops: 0, }); // Only the Person vector index ran. Organization vector index does not // exist; the loop body skipped it instead of bailing. expect(vectorCalls).toEqual(["person_embedding"]); // BM25 returned both rows under the label gate (the lib forwards // `labels` to bm25Only verbatim — the post-bail invariant we assert). const ids = res.results.map((r) => r.nodeId).sort(); expect(ids).toEqual(["org-1", "p1"]); }); });