569 lines
19 KiB
TypeScript
569 lines
19 KiB
TypeScript
/// <reference types="jest" />
|
|
/**
|
|
* Tests for Qdrant native BM25 keyword search (server-side `Qdrant/bm25`
|
|
* inference). Fully mocked — no real Qdrant server is required.
|
|
*
|
|
* Covers both the happy path and the failure modes that matter for an opt-in,
|
|
* capability-gated feature:
|
|
* - fresh collections get the `bm25` sparse slot (modifier idf); migrations don't,
|
|
* - insert/update attach the server-side BM25 inference vector,
|
|
* - keywordSearch queries the `bm25` slot and maps results to the common shape,
|
|
* - collection-init variations (slot present/absent, wrong size, 401/403/409,
|
|
* transient verify failure, fatal error),
|
|
* - malformed/hostile query responses and filter construction,
|
|
* all proving the adapter degrades to `null` (semantic-only) instead of crashing.
|
|
*/
|
|
|
|
jest.setTimeout(15000);
|
|
|
|
type MockClient = Record<string, jest.Mock>;
|
|
|
|
function buildMockClient(
|
|
overrides: Partial<MockClient> = {},
|
|
collectionInfo: any = { config: { params: { vectors: { size: 768 } } } },
|
|
): MockClient {
|
|
return {
|
|
createCollection: jest.fn().mockResolvedValue(undefined),
|
|
createPayloadIndex: jest.fn().mockResolvedValue(undefined),
|
|
getCollection: jest.fn().mockResolvedValue(collectionInfo),
|
|
scroll: jest.fn().mockResolvedValue({ points: [] }),
|
|
upsert: jest.fn().mockResolvedValue(undefined),
|
|
retrieve: jest.fn().mockResolvedValue([]),
|
|
search: jest.fn().mockResolvedValue([]),
|
|
query: jest.fn().mockResolvedValue({ points: [] }),
|
|
delete: jest.fn().mockResolvedValue(undefined),
|
|
deleteCollection: jest.fn().mockResolvedValue(undefined),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
jest.mock("@qdrant/js-client-rest", () => ({
|
|
QdrantClient: jest.fn().mockImplementation(() => buildMockClient()),
|
|
}));
|
|
|
|
import { Qdrant } from "../src/vector_stores/qdrant";
|
|
|
|
const BASE_CONFIG = {
|
|
collectionName: "test_memories",
|
|
embeddingModelDims: 768,
|
|
dimension: 768,
|
|
};
|
|
|
|
// Existing collection that already carries a bm25 slot.
|
|
const INFO_WITH_SLOT = {
|
|
config: {
|
|
params: { vectors: { size: 768 }, sparse_vectors: { bm25: {} } },
|
|
},
|
|
};
|
|
// Existing collection without a bm25 slot (legacy / pre-hybrid-search).
|
|
const INFO_NO_SLOT = {
|
|
config: { params: { vectors: { size: 768 } } },
|
|
};
|
|
|
|
let warnSpy: jest.SpyInstance;
|
|
let errSpy: jest.SpyInstance;
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {});
|
|
errSpy = jest.spyOn(console, "error").mockImplementation(() => {});
|
|
});
|
|
|
|
afterEach(() => {
|
|
warnSpy.mockRestore();
|
|
errSpy.mockRestore();
|
|
});
|
|
|
|
function makeStore(client: MockClient): Qdrant {
|
|
return new Qdrant({ client: client as any, ...BASE_CONFIG });
|
|
}
|
|
|
|
describe("Qdrant BM25 keyword search — collection setup", () => {
|
|
it("creates the main collection with the bm25 sparse slot (modifier idf)", async () => {
|
|
const client = buildMockClient();
|
|
const store = makeStore(client);
|
|
await store.initialize();
|
|
|
|
const createCall = client.createCollection.mock.calls.find(
|
|
(c) => c[0] === "test_memories",
|
|
);
|
|
expect(createCall).toBeDefined();
|
|
expect(createCall![1].sparse_vectors).toEqual({
|
|
bm25: { modifier: "idf" },
|
|
});
|
|
});
|
|
|
|
it("does NOT add the bm25 slot to the memory_migrations collection", async () => {
|
|
const client = buildMockClient();
|
|
const store = makeStore(client);
|
|
await store.initialize();
|
|
|
|
const migrationsCall = client.createCollection.mock.calls.find(
|
|
(c) => c[0] === "memory_migrations",
|
|
);
|
|
expect(migrationsCall).toBeDefined();
|
|
expect(migrationsCall![1].sparse_vectors).toBeUndefined();
|
|
});
|
|
|
|
it("409 with an existing bm25 slot → enables keyword search, no warning", async () => {
|
|
const client = buildMockClient(
|
|
{ createCollection: jest.fn().mockRejectedValue({ status: 409 }) },
|
|
INFO_WITH_SLOT,
|
|
);
|
|
const store = makeStore(client);
|
|
await store.initialize();
|
|
|
|
expect(await store.keywordSearch("anything")).not.toBeNull();
|
|
expect(client.query).toHaveBeenCalled();
|
|
expect(warnSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("409 without a bm25 slot → disabled + warns once", async () => {
|
|
const client = buildMockClient(
|
|
{ createCollection: jest.fn().mockRejectedValue({ status: 409 }) },
|
|
INFO_NO_SLOT,
|
|
);
|
|
const store = makeStore(client);
|
|
await store.initialize();
|
|
|
|
expect(await store.keywordSearch("anything")).toBeNull();
|
|
expect(client.query).not.toHaveBeenCalled();
|
|
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("401 (auth-restricted existing collection) with slot → enabled", async () => {
|
|
const client = buildMockClient(
|
|
{ createCollection: jest.fn().mockRejectedValue({ status: 401 }) },
|
|
INFO_WITH_SLOT,
|
|
);
|
|
const store = makeStore(client);
|
|
await store.initialize();
|
|
expect(await store.keywordSearch("x")).not.toBeNull();
|
|
});
|
|
|
|
it("403 with no slot → disabled (graceful)", async () => {
|
|
const client = buildMockClient(
|
|
{ createCollection: jest.fn().mockRejectedValue({ status: 403 }) },
|
|
INFO_NO_SLOT,
|
|
);
|
|
const store = makeStore(client);
|
|
await store.initialize();
|
|
expect(await store.keywordSearch("x")).toBeNull();
|
|
});
|
|
|
|
it("existing collection with WRONG vector size → init rejects (size guard intact)", async () => {
|
|
const client = buildMockClient(
|
|
{ createCollection: jest.fn().mockRejectedValue({ status: 409 }) },
|
|
{ config: { params: { vectors: { size: 1536 } } } }, // mismatch vs 768
|
|
);
|
|
const store = makeStore(client);
|
|
await expect(store.initialize()).rejects.toThrow(/wrong vector size/i);
|
|
});
|
|
|
|
it("transient getCollection failure during verify → does NOT crash init, stays disabled", async () => {
|
|
const client = buildMockClient({
|
|
createCollection: jest.fn().mockRejectedValue({ status: 409 }),
|
|
getCollection: jest
|
|
.fn()
|
|
.mockRejectedValue({ status: 500, message: "committing" }),
|
|
});
|
|
const store = makeStore(client);
|
|
await expect(store.initialize()).resolves.toBeUndefined();
|
|
expect(await store.keywordSearch("x")).toBeNull();
|
|
});
|
|
|
|
it("fatal createCollection error (500, not 401/403/409) → init rejects", async () => {
|
|
const client = buildMockClient({
|
|
createCollection: jest.fn().mockRejectedValue({ status: 500 }),
|
|
});
|
|
const store = makeStore(client);
|
|
await expect(store.initialize()).rejects.toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe("Qdrant BM25 keyword search — insert / update", () => {
|
|
async function freshStore(): Promise<{ store: Qdrant; client: MockClient }> {
|
|
const client = buildMockClient();
|
|
const store = makeStore(client);
|
|
await store.initialize();
|
|
return { store, client };
|
|
}
|
|
|
|
it("attaches a server-side BM25 inference vector on insert", async () => {
|
|
const { store, client } = await freshStore();
|
|
await store.insert(
|
|
[[0.1, 0.2, 0.3]],
|
|
["mem-1"],
|
|
[
|
|
{
|
|
data: "Alice reported a duplicate AWS invoice",
|
|
textLemmatized: "alice report duplicate aws invoice",
|
|
},
|
|
],
|
|
);
|
|
|
|
const point = client.upsert.mock.calls.at(-1)![1].points[0];
|
|
expect(point.id).toBe("mem-1");
|
|
expect(point.vector[""]).toEqual([0.1, 0.2, 0.3]);
|
|
expect(point.vector.bm25).toEqual({
|
|
text: "alice report duplicate aws invoice",
|
|
model: "Qdrant/bm25",
|
|
});
|
|
});
|
|
|
|
it("falls back to payload.data when textLemmatized is absent", async () => {
|
|
const { store, client } = await freshStore();
|
|
await store.insert([[0.1]], ["mem-2"], [{ data: "plain text" }]);
|
|
|
|
const point = client.upsert.mock.calls.at(-1)![1].points[0];
|
|
expect(point.vector.bm25).toEqual({
|
|
text: "plain text",
|
|
model: "Qdrant/bm25",
|
|
});
|
|
});
|
|
|
|
it("empty textLemmatized falls through to data", async () => {
|
|
const { store, client } = await freshStore();
|
|
await store.insert(
|
|
[[0.1]],
|
|
["m"],
|
|
[{ textLemmatized: "", data: "fallback" }],
|
|
);
|
|
const point = client.upsert.mock.calls.at(-1)![1].points[0];
|
|
expect(point.vector.bm25).toEqual({
|
|
text: "fallback",
|
|
model: "Qdrant/bm25",
|
|
});
|
|
});
|
|
|
|
it("omits the bm25 vector when the point has no text (dense still present)", async () => {
|
|
const { store, client } = await freshStore();
|
|
await store.insert([[0.1, 0.2]], ["m"], [{ textLemmatized: "", data: "" }]);
|
|
const point = client.upsert.mock.calls.at(-1)![1].points[0];
|
|
expect(point.vector[""]).toEqual([0.1, 0.2]);
|
|
expect(point.vector.bm25).toBeUndefined();
|
|
});
|
|
|
|
it("preserves the given id on insert", async () => {
|
|
const { store, client } = await freshStore();
|
|
await store.insert([[0.1]], ["123"], [{ data: "x" }]);
|
|
const point = client.upsert.mock.calls.at(-1)![1].points[0];
|
|
expect(point.id).toBe("123");
|
|
});
|
|
|
|
it("batch insert attaches bm25 to every text-bearing point", async () => {
|
|
const { store, client } = await freshStore();
|
|
await store.insert(
|
|
[[0.1], [0.2], [0.3]],
|
|
["a", "b", "c"],
|
|
[{ data: "one" }, { data: "" }, { textLemmatized: "three" }],
|
|
);
|
|
const points = client.upsert.mock.calls.at(-1)![1].points;
|
|
expect(points[0].vector.bm25.text).toBe("one");
|
|
expect(points[1].vector.bm25).toBeUndefined();
|
|
expect(points[2].vector.bm25.text).toBe("three");
|
|
});
|
|
|
|
it("re-encodes the bm25 vector on update", async () => {
|
|
const { store, client } = await freshStore();
|
|
await store.update("mem-1", [0.4, 0.5], {
|
|
data: "updated",
|
|
textLemmatized: "updat",
|
|
});
|
|
const point = client.upsert.mock.calls.at(-1)![1].points[0];
|
|
expect(point.vector[""]).toEqual([0.4, 0.5]);
|
|
expect(point.vector.bm25).toEqual({ text: "updat", model: "Qdrant/bm25" });
|
|
});
|
|
|
|
it("update with no text → named dense only (bm25 not re-attached)", async () => {
|
|
const { store, client } = await freshStore();
|
|
await store.update("m", [0.9], { user_id: "u" });
|
|
const point = client.upsert.mock.calls.at(-1)![1].points[0];
|
|
expect(point.vector[""]).toEqual([0.9]);
|
|
expect(point.vector.bm25).toBeUndefined();
|
|
});
|
|
|
|
it("inserts a plain dense vector (no named/sparse) on legacy collections", async () => {
|
|
const client = buildMockClient(
|
|
{ createCollection: jest.fn().mockRejectedValue({ status: 409 }) },
|
|
INFO_NO_SLOT,
|
|
);
|
|
const store = makeStore(client);
|
|
await store.initialize();
|
|
|
|
await store.insert(
|
|
[[0.1, 0.2]],
|
|
["mem-1"],
|
|
[{ data: "hello", textLemmatized: "hello" }],
|
|
);
|
|
|
|
const point = client.upsert.mock.calls.at(-1)![1].points[0];
|
|
expect(point.vector).toEqual([0.1, 0.2]);
|
|
});
|
|
});
|
|
|
|
describe("Qdrant BM25 keyword search — querying & result mapping", () => {
|
|
it("queries the bm25 slot and maps results to {id, payload, score}", async () => {
|
|
const client = buildMockClient({
|
|
query: jest.fn().mockResolvedValue({
|
|
points: [
|
|
{ id: "mem-1", score: 4.2, payload: { data: "AWS invoice" } },
|
|
{ id: "mem-2", score: 1.1, payload: { data: "other" } },
|
|
],
|
|
}),
|
|
});
|
|
const store = makeStore(client);
|
|
await store.initialize();
|
|
|
|
const results = await store.keywordSearch("aws invoice", 5, {
|
|
user_id: "u1",
|
|
});
|
|
|
|
expect(client.query).toHaveBeenCalledWith(
|
|
"test_memories",
|
|
expect.objectContaining({
|
|
query: { text: "aws invoice", model: "Qdrant/bm25" },
|
|
using: "bm25",
|
|
limit: 5,
|
|
with_payload: true,
|
|
}),
|
|
);
|
|
expect(results).toEqual([
|
|
{ id: "mem-1", payload: { data: "AWS invoice" }, score: 4.2 },
|
|
{ id: "mem-2", payload: { data: "other" }, score: 1.1 },
|
|
]);
|
|
});
|
|
|
|
async function storeWith(queryImpl: jest.Mock): Promise<Qdrant> {
|
|
const client = buildMockClient({ query: queryImpl });
|
|
const store = makeStore(client);
|
|
await store.initialize();
|
|
return store;
|
|
}
|
|
|
|
it("query rejects → null (no throw)", async () => {
|
|
const store = await storeWith(
|
|
jest.fn().mockRejectedValue(new Error("boom")),
|
|
);
|
|
expect(await store.keywordSearch("x")).toBeNull();
|
|
expect(errSpy).toHaveBeenCalled();
|
|
});
|
|
|
|
it("response missing the `points` array → null (no throw)", async () => {
|
|
const store = await storeWith(jest.fn().mockResolvedValue({}));
|
|
expect(await store.keywordSearch("x")).toBeNull();
|
|
expect(errSpy).toHaveBeenCalled();
|
|
});
|
|
|
|
it("response is null → null (no throw)", async () => {
|
|
const store = await storeWith(jest.fn().mockResolvedValue(null));
|
|
expect(await store.keywordSearch("x")).toBeNull();
|
|
});
|
|
|
|
it("point with numeric id → stringified", async () => {
|
|
const store = await storeWith(
|
|
jest.fn().mockResolvedValue({
|
|
points: [{ id: 42, score: 1.0, payload: { data: "d" } }],
|
|
}),
|
|
);
|
|
const res = await store.keywordSearch("x");
|
|
expect(res![0].id).toBe("42");
|
|
});
|
|
|
|
it("point with missing score → score undefined (pipeline coerces to 0)", async () => {
|
|
const store = await storeWith(
|
|
jest.fn().mockResolvedValue({ points: [{ id: "a", payload: {} }] }),
|
|
);
|
|
const res = await store.keywordSearch("x");
|
|
expect(res![0].score).toBeUndefined();
|
|
});
|
|
|
|
it("point with missing payload → {}", async () => {
|
|
const store = await storeWith(
|
|
jest.fn().mockResolvedValue({ points: [{ id: "a", score: 2 }] }),
|
|
);
|
|
const res = await store.keywordSearch("x");
|
|
expect(res![0].payload).toEqual({});
|
|
});
|
|
|
|
it("empty query string is forwarded and yields [] (graceful)", async () => {
|
|
const queryImpl = jest.fn().mockResolvedValue({ points: [] });
|
|
const store = await storeWith(queryImpl);
|
|
const res = await store.keywordSearch("");
|
|
expect(res).toEqual([]);
|
|
expect(queryImpl.mock.calls[0][1].query).toEqual({
|
|
text: "",
|
|
model: "Qdrant/bm25",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("Qdrant BM25 keyword search — query construction & filters", () => {
|
|
async function freshStore(): Promise<{ store: Qdrant; client: MockClient }> {
|
|
const client = buildMockClient();
|
|
const store = makeStore(client);
|
|
await store.initialize();
|
|
return { store, client };
|
|
}
|
|
|
|
it("defaults topK to 5 when omitted", async () => {
|
|
const { store, client } = await freshStore();
|
|
await store.keywordSearch("x");
|
|
expect(client.query.mock.calls[0][1].limit).toBe(5);
|
|
});
|
|
|
|
it("passes a simple equality filter through createFilter", async () => {
|
|
const { store, client } = await freshStore();
|
|
await store.keywordSearch("x", 3, { user_id: "u1" });
|
|
const arg = client.query.mock.calls[0][1];
|
|
expect(arg.limit).toBe(3);
|
|
expect(arg.using).toBe("bm25");
|
|
expect(arg.filter).toEqual({
|
|
must: [{ key: "user_id", match: { value: "u1" } }],
|
|
});
|
|
});
|
|
|
|
it("builds an OR (should) clause for logical filters", async () => {
|
|
const { store, client } = await freshStore();
|
|
await store.keywordSearch("x", 5, {
|
|
$or: [{ user_id: "u1" }, { agent_id: "a1" }],
|
|
});
|
|
const filter = client.query.mock.calls[0][1].filter;
|
|
expect(filter.should).toHaveLength(2);
|
|
});
|
|
|
|
it("empty filter object → filter is undefined", async () => {
|
|
const { store, client } = await freshStore();
|
|
await store.keywordSearch("x", 5, {});
|
|
expect(client.query.mock.calls[0][1].filter).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("Qdrant BM25 keyword search — reactive downgrade on upsert failure", () => {
|
|
it("insert(): upsert fails once, retries with plain dense vector, disables bm25", async () => {
|
|
const upsert = jest
|
|
.fn()
|
|
.mockRejectedValueOnce({
|
|
status: 500,
|
|
message: "InferenceService is not initialized.",
|
|
})
|
|
.mockResolvedValueOnce(undefined);
|
|
const client = buildMockClient({ upsert });
|
|
const store = makeStore(client);
|
|
await store.initialize();
|
|
|
|
await store.insert(
|
|
[[0.1, 0.2]],
|
|
["mem-1"],
|
|
[{ data: "hello", textLemmatized: "hello" }],
|
|
);
|
|
|
|
expect(upsert).toHaveBeenCalledTimes(2);
|
|
const retryPoint = upsert.mock.calls[1][1].points[0];
|
|
expect(retryPoint.vector).toEqual([0.1, 0.2]);
|
|
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
expect(await store.keywordSearch("hello")).toBeNull();
|
|
});
|
|
|
|
it("update(): upsert fails once, retries with plain dense vector, disables bm25", async () => {
|
|
const upsert = jest
|
|
.fn()
|
|
.mockRejectedValueOnce({
|
|
status: 500,
|
|
message: "InferenceService is not initialized.",
|
|
})
|
|
.mockResolvedValueOnce(undefined);
|
|
const client = buildMockClient({ upsert });
|
|
const store = makeStore(client);
|
|
await store.initialize();
|
|
|
|
await store.update("mem-1", [0.4, 0.5], {
|
|
data: "updated",
|
|
textLemmatized: "updat",
|
|
});
|
|
|
|
expect(upsert).toHaveBeenCalledTimes(2);
|
|
const retryPoint = upsert.mock.calls[1][1].points[0];
|
|
expect(retryPoint.vector).toEqual([0.4, 0.5]);
|
|
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
expect(await store.keywordSearch("updat")).toBeNull();
|
|
});
|
|
|
|
it("legacy collection (no bm25 slot): a genuine upsert failure still rejects, not swallowed", async () => {
|
|
const client = buildMockClient(
|
|
{
|
|
createCollection: jest.fn().mockRejectedValue({ status: 409 }),
|
|
upsert: jest.fn().mockRejectedValue(new Error("network down")),
|
|
},
|
|
INFO_NO_SLOT,
|
|
);
|
|
const store = makeStore(client);
|
|
await store.initialize();
|
|
|
|
await expect(store.insert([[0.1]], ["m"], [{ data: "x" }])).rejects.toThrow(
|
|
"network down",
|
|
);
|
|
expect(client.upsert).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("after a downgrade, a subsequent insert() sends a plain dense vector on the first attempt", async () => {
|
|
const upsert = jest
|
|
.fn()
|
|
.mockRejectedValueOnce({
|
|
status: 500,
|
|
message: "InferenceService is not initialized.",
|
|
})
|
|
.mockResolvedValueOnce(undefined)
|
|
.mockResolvedValueOnce(undefined);
|
|
const client = buildMockClient({ upsert });
|
|
const store = makeStore(client);
|
|
await store.initialize();
|
|
|
|
await store.insert([[0.1]], ["mem-1"], [{ data: "first" }]);
|
|
expect(upsert).toHaveBeenCalledTimes(2);
|
|
|
|
await store.insert([[0.2]], ["mem-2"], [{ data: "second" }]);
|
|
expect(upsert).toHaveBeenCalledTimes(3);
|
|
const secondInsertPoint = upsert.mock.calls[2][1].points[0];
|
|
expect(secondInsertPoint.vector).toEqual([0.2]);
|
|
});
|
|
});
|
|
|
|
describe("Qdrant BM25 keyword search — memory_migrations indexing", () => {
|
|
it("does not create payload indexes for memory_migrations on the existing-collection path", async () => {
|
|
const client = buildMockClient({
|
|
createCollection: jest.fn().mockRejectedValue({ status: 409 }),
|
|
});
|
|
const store = makeStore(client);
|
|
await store.initialize();
|
|
|
|
const migrationsIndexCalls = client.createPayloadIndex.mock.calls.filter(
|
|
(c) => c[0] === "memory_migrations",
|
|
);
|
|
expect(migrationsIndexCalls).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe("Qdrant BM25 keyword search — semantic (dense) path", () => {
|
|
it("search() still targets the unnamed dense slot after BM25 init", async () => {
|
|
const client = buildMockClient({
|
|
search: jest
|
|
.fn()
|
|
.mockResolvedValue([
|
|
{ id: "mem-1", score: 0.9, payload: { data: "x" } },
|
|
]),
|
|
});
|
|
const store = makeStore(client);
|
|
await store.initialize();
|
|
|
|
const res = await store.search([0.1, 0.2, 0.3], 5);
|
|
|
|
// Plain number[] (not a named vector) keeps hitting the default dense slot.
|
|
expect(client.search).toHaveBeenCalledWith(
|
|
"test_memories",
|
|
expect.objectContaining({ vector: [0.1, 0.2, 0.3] }),
|
|
);
|
|
expect(res[0].id).toBe("mem-1");
|
|
});
|
|
});
|