import { describe, it, expect, beforeEach } from "vitest"; import { hybrid, clearIndexCache } from "../index.js"; /** * vector-cosine threshold filter. * * Pre-fix: kNN vector index returned every node in scope, regardless of * similarity. With limit > population, all rows passed through to the * UI. Real Agent regression: chip=Project q=savage returned all 12 * Project nodes (vector cosine 0.5–0.7 on 11 noise rows; only Brochure * Production — John Savage was relevant). * * This file pins: * 1. threshold drops rows below the floor * 2. counts (rawMerged / suppressed) reflect the partition * 3. threshold=0 matches "no threshold" semantically (show all) * 4. undefined threshold preserves legacy behaviour (no filter) */ 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 filter", () => { it("suppresses rows with vector cosine below the threshold (no BM25 hits)", async () => { // Mirror the Real Agent savage-case shape: 4 nodes, vector scores // [0.9, 0.65, 0.55, 0.50], no BM25 hits. Threshold 0.7 keeps only // the top row. 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: "n1", nodeLabels: ["Project"], node: { properties: { name: "relevant" } }, score: 0.9 }, { nodeId: "n2", nodeLabels: ["Project"], node: { properties: { name: "noise-a" } }, score: 0.65 }, { nodeId: "n3", nodeLabels: ["Project"], node: { properties: { name: "noise-b" } }, score: 0.55 }, { nodeId: "n4", nodeLabels: ["Project"], node: { properties: { name: "noise-c" } }, score: 0.50 }, ], }, // BM25 returns nothing for this query. { match: (q) => q.includes("db.index.fulltext.queryNodes"), records: [] }, ]); const res = await hybrid(session, async () => [0.1, 0.2, 0.3], { query: "savage", accountId: "acc-1", limit: 100, labels: ["Project"], expandHops: 0, vectorThreshold: 0.7, }); expect(res.results.map((r) => r.nodeId)).toEqual(["n1"]); expect(res.rawMerged).toBe(4); expect(res.suppressed).toBe(3); expect(res.bm25Bypass).toBe(0); expect(res.threshold).toBe(0.7); }); it("undefined threshold preserves legacy behaviour (no filter)", async () => { 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: "n1", nodeLabels: ["Project"], node: { properties: {} }, score: 0.5 }, { nodeId: "n2", nodeLabels: ["Project"], node: { properties: {} }, score: 0.3 }, ], }, { match: (q) => q.includes("db.index.fulltext.queryNodes"), records: [] }, ]); const res = await hybrid(session, async () => [0.1], { query: "x", accountId: "a", limit: 10, labels: ["Project"], expandHops: 0, // vectorThreshold omitted }); expect(res.results).toHaveLength(2); expect(res.suppressed).toBe(0); expect(res.threshold).toBeNull(); }); it("threshold=0 keeps every row (operator's show-all override)", async () => { 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: "n1", nodeLabels: ["Project"], node: { properties: {} }, score: 0.5 }, { nodeId: "n2", nodeLabels: ["Project"], node: { properties: {} }, score: 0.3 }, ], }, { match: (q) => q.includes("db.index.fulltext.queryNodes"), records: [] }, ]); const res = await hybrid(session, async () => [0.1], { query: "x", accountId: "a", limit: 10, labels: ["Project"], expandHops: 0, vectorThreshold: 0, }); expect(res.results).toHaveLength(2); expect(res.suppressed).toBe(0); expect(res.threshold).toBe(0); }); it("filter applies BEFORE slice — under-fill regression guard", async () => { // 5 rows: 2 above threshold, 3 below. limit=3. // Wrong order (slice then filter) would yield 2 rows from the top-3. // Correct order (filter then sort then slice) yields both above-threshold // rows because they survive the filter and limit ≥ kept-set. 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: "n1", nodeLabels: ["Project"], node: { properties: {} }, score: 0.95 }, { nodeId: "n2", nodeLabels: ["Project"], node: { properties: {} }, score: 0.60 }, { nodeId: "n3", nodeLabels: ["Project"], node: { properties: {} }, score: 0.55 }, { nodeId: "n4", nodeLabels: ["Project"], node: { properties: {} }, score: 0.50 }, { nodeId: "n5", nodeLabels: ["Project"], node: { properties: {} }, score: 0.85 }, ], }, { match: (q) => q.includes("db.index.fulltext.queryNodes"), records: [] }, ]); const res = await hybrid(session, async () => [0.1], { query: "x", accountId: "a", limit: 3, labels: ["Project"], expandHops: 0, vectorThreshold: 0.8, }); expect(res.results.map((r) => r.nodeId).sort()).toEqual(["n1", "n5"]); expect(res.rawMerged).toBe(5); expect(res.suppressed).toBe(3); }); });