feat(evidence): add Qdrant BM25 vector in place

This commit is contained in:
2026-08-24 18:01:46 +02:00
parent ae0976a4aa
commit 0e9add09a9
11 changed files with 320 additions and 14 deletions
+84
View File
@@ -28,6 +28,90 @@ const compatible = (size = 1024, distance = "Cosine", schema = payloadSchema) =>
payload_schema: schema,
});
test("Evidence maintenance adds an absent BM25 vector without changing the dense contract", async () => {
const info = compatible();
const requests: Array<{ url: string; init?: any }> = [];
const request = async (url: string, init?: any) => {
requests.push({ url, init });
if (init?.method === "PUT" && /\/vectors\/bm25$/.test(url)) {
expect(JSON.parse(String(init.body))).toEqual({ sparse: { modifier: "idf" } });
(info.config.params as any).sparse_vectors = { bm25: { modifier: "idf" } };
return { status: 200, ok: true, json: async () => ({}) } as any;
}
return { status: 200, ok: true, json: async () => ({ result: info }) } as any;
};
const result = await reconcileCollection({
baseUrl: "http://qdrant:6333", collection: "c", dimensions: 1024, distance: "cosine",
mode: "evidence_maintenance", request,
});
expect(result).toEqual({ ok: true, state: "upgraded" });
expect(info.config.params.vectors).toEqual({ size: 1024, distance: "Cosine" });
expect(requests.filter(({ init }) => init?.method === "PUT")).toHaveLength(1);
expect(requests[1]?.url).toBe("http://qdrant:6333/collections/c/vectors/bm25");
});
test("Evidence maintenance creates a missing collection with both required vector contracts", async () => {
let info: any;
let createdBody: any;
const request = async (url: string, init?: any) => {
if (init?.method === "PUT") {
createdBody = JSON.parse(String(init.body));
info = { config: { params: createdBody }, payload_schema: payloadSchema };
return { status: 200, ok: true, json: async () => ({}) } as any;
}
if (info === undefined) return { status: 404, ok: false, json: async () => ({}) } as any;
return { status: 200, ok: true, json: async () => ({ result: info }) } as any;
};
const result = await reconcileCollection({
baseUrl: "http://qdrant:6333", collection: "c", dimensions: 1024, distance: "cosine",
mode: "evidence_maintenance", request,
});
expect(result).toEqual({ ok: true, state: "ready" });
expect(createdBody).toEqual({
vectors: { size: 1024, distance: "Cosine" },
sparse_vectors: { bm25: { modifier: "idf" } },
});
});
test("Evidence maintenance refuses an incompatible BM25 definition without mutating", async () => {
const info = compatible();
(info.config.params as any).sparse_vectors = { bm25: { modifier: "none" } };
const requests: Array<{ url: string; init?: any }> = [];
const request = async (url: string, init?: any) => {
requests.push({ url, init });
return { status: 200, ok: true, json: async () => ({ result: info }) } as any;
};
const result = await reconcileCollection({
baseUrl: "http://qdrant:6333", collection: "c", dimensions: 1024, distance: "cosine",
mode: "evidence_maintenance", request,
});
expect(result).toEqual({ ok: false, code: "semantic_index_incompatible" });
expect(requests.filter(({ init }) => init?.method === "PUT")).toEqual([]);
});
test("ordinary session reconciliation does not add BM25", async () => {
const info = compatible();
const requests: Array<{ url: string; init?: any }> = [];
const request = async (url: string, init?: any) => {
requests.push({ url, init });
return { status: 200, ok: true, json: async () => ({ result: info }) } as any;
};
const result = await reconcileCollection({
baseUrl: "http://qdrant:6333", collection: "c", dimensions: 1024, distance: "cosine",
mode: "self_heal", request,
});
expect(result).toEqual({ ok: true, state: "ready" });
expect(requests.filter(({ url }) => /\/vectors\/bm25$/.test(url))).toEqual([]);
});
test("self-heal creates a missing compatible collection", async () => {
const r = await reconcileCollection({
baseUrl: "http://qdrant:6333", collection: "c", dimensions: 1024, distance: "cosine",
@@ -161,6 +161,7 @@ function fixture(workspace = baseWorkspace) {
runChild,
listSessions: async () => [],
semanticPreflight: async () => ({ ok: true }),
evidencePreflight: async () => ({ ok: true }),
});
return { dataRoot, runChild, requests, service };
}
@@ -283,6 +284,7 @@ test("index schema fails closed when semantic preflight refuses the collection",
runChild,
listSessions: async () => [],
semanticPreflight: async () => ({ ok: false, code: "semantic_index_incompatible" }),
evidencePreflight: async () => ({ ok: true }),
});
const result = await service.indexSchema({ workspaceId: "psd-clinical" });
@@ -309,6 +311,7 @@ test("filesystem Evidence proceeds after materialization and private HTTP hosts
runChild: vi.fn(),
listSessions: async () => [],
semanticPreflight: async () => ({ ok: true }),
evidencePreflight: async () => ({ ok: true }),
httpPrivateHostAllowlist: ["metadata.internal"],
});
@@ -513,6 +516,7 @@ test("vector rebuild recreates the full collection contract including keyword in
runChild: vi.fn(),
listSessions: async () => [],
semanticPreflight: async () => ({ ok: true }),
evidencePreflight: async () => ({ ok: true }),
});
// replace global fetch used by vectorRebuild/reconcileCollection
const original = globalThis.fetch;
@@ -40,11 +40,13 @@ function dependencies(payload: Record<string, unknown> = {}): EvidencePreprocess
runStage: ReturnType<typeof vi.fn>;
persistJob: ReturnType<typeof vi.fn>;
semanticPreflight: ReturnType<typeof vi.fn>;
evidencePreflight: ReturnType<typeof vi.fn>;
} {
return {
runStage: vi.fn(async () => payload),
persistJob: vi.fn(),
semanticPreflight: vi.fn(async () => ({ ok: true as const })),
evidencePreflight: vi.fn(async () => ({ ok: true as const })),
requireRunId(value) {
if (typeof value !== "string" || !/^[0-9a-f]{32}$/.test(value)) {
throw new Error("child run id is invalid");
@@ -60,7 +62,7 @@ function dependencies(payload: Record<string, unknown> = {}): EvidencePreprocess
};
}
test("owns the standalone Evidence stage argv and mutation order", async () => {
test("Evidence maintenance preflights the additive BM25 contract before starting its stage", async () => {
const state = job({ childRuns: { evidence: "b".repeat(32) } });
const deps = dependencies({ run_id: "c".repeat(32), counts: { added: 2 } });
@@ -69,7 +71,8 @@ test("owns the standalone Evidence stage argv and mutation order", async () => {
deps,
);
expect(deps.semanticPreflight).toHaveBeenCalledOnce();
expect(deps.evidencePreflight).toHaveBeenCalledOnce();
expect(deps.semanticPreflight).not.toHaveBeenCalled();
expect(deps.runStage).toHaveBeenCalledWith([
"preprocess", "evidence", "--resume", "b".repeat(32), "--json", "-c", "/dev/fd/3",
]);