Co-authored-by: kartik-mem0 <kartik.labhshetwar@mem0.ai>
This commit is contained in:
@@ -41,6 +41,7 @@ export * from "./vector_stores/s3_vectors";
|
||||
export * from "./vector_stores/vertex_ai_vector_search";
|
||||
export * from "./vector_stores/pinecone";
|
||||
export * from "./vector_stores/turbopuffer";
|
||||
export * from "./vector_stores/milvus";
|
||||
export * from "./vector_stores/mongodb";
|
||||
export * from "./vector_stores/opensearch";
|
||||
export * from "./utils/factory";
|
||||
|
||||
@@ -0,0 +1,517 @@
|
||||
import { Milvus } from "../vector_stores/milvus";
|
||||
|
||||
/**
|
||||
* In-memory fake of the subset of the `@zilliz/milvus2-sdk-node` MilvusClient
|
||||
* API that the Milvus vector store uses. Lets us exercise the provider's
|
||||
* request shaping and response parsing without a live Milvus server or the SDK.
|
||||
*/
|
||||
class FakeMilvusClient {
|
||||
public calls: { method: string; args: any }[] = [];
|
||||
private collections = new Set<string>();
|
||||
// collection -> id -> row
|
||||
private store: Record<string, Record<string, any>> = {};
|
||||
// collection -> declared vector dimension (recorded from createCollection)
|
||||
private dims: Record<string, number> = {};
|
||||
// collection -> declared field names (surfaced via describeCollection)
|
||||
private fieldNames: Record<string, string[]> = {};
|
||||
|
||||
// Allow tests to script search responses.
|
||||
public searchResponse: any = { results: [] };
|
||||
|
||||
constructor(opts?: { existing?: string[]; bm25?: string[] }) {
|
||||
// Legacy dense-only collections: no text/sparse fields.
|
||||
for (const c of opts?.existing || []) {
|
||||
this.collections.add(c);
|
||||
this.store[c] = {};
|
||||
this.fieldNames[c] = ["id", "vectors", "metadata"];
|
||||
}
|
||||
// Pre-existing collections that already carry the BM25 schema.
|
||||
for (const c of opts?.bm25 || []) {
|
||||
this.collections.add(c);
|
||||
this.store[c] = {};
|
||||
this.fieldNames[c] = ["id", "vectors", "metadata", "text", "sparse"];
|
||||
}
|
||||
}
|
||||
|
||||
async hasCollection({ collection_name }: any) {
|
||||
this.calls.push({ method: "hasCollection", args: { collection_name } });
|
||||
return { value: this.collections.has(collection_name) };
|
||||
}
|
||||
|
||||
async createCollection(args: any) {
|
||||
this.calls.push({ method: "createCollection", args });
|
||||
// Mirror Milvus's real server constraint: a FloatVector field's dim must be
|
||||
// in [2, 32768]. Vector fields are the ones carrying a numeric `dim`.
|
||||
for (const f of args.fields || []) {
|
||||
if (typeof f.dim === "number" && (f.dim < 2 || f.dim > 32768)) {
|
||||
throw new Error(
|
||||
`invalid dimension: ${f.dim}. should be in range 2 ~ 32768`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const vectorField = (args.fields || []).find(
|
||||
(f: any) => typeof f.dim === "number",
|
||||
);
|
||||
if (vectorField) this.dims[args.collection_name] = vectorField.dim;
|
||||
this.fieldNames[args.collection_name] = (args.fields || []).map(
|
||||
(f: any) => f.name,
|
||||
);
|
||||
this.collections.add(args.collection_name);
|
||||
this.store[args.collection_name] = this.store[args.collection_name] || {};
|
||||
}
|
||||
|
||||
async describeCollection({ collection_name }: any) {
|
||||
this.calls.push({
|
||||
method: "describeCollection",
|
||||
args: { collection_name },
|
||||
});
|
||||
const fields = (this.fieldNames[collection_name] || []).map((name) => ({
|
||||
name,
|
||||
}));
|
||||
return { schema: { fields } };
|
||||
}
|
||||
|
||||
// Reject rows whose vector length disagrees with the collection's declared
|
||||
// dim, exactly as the real server would.
|
||||
private checkDims(collection: string, data: any[]) {
|
||||
const dim = this.dims[collection];
|
||||
if (dim == null) return; // pre-seeded collection: dim not tracked
|
||||
for (const row of data || []) {
|
||||
if (Array.isArray(row.vectors) && row.vectors.length !== dim) {
|
||||
throw new Error(
|
||||
`vector dimension mismatch: expected ${dim}, got ${row.vectors.length}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async loadCollection(args: any) {
|
||||
this.calls.push({ method: "loadCollection", args });
|
||||
}
|
||||
|
||||
async dropCollection(args: any) {
|
||||
this.calls.push({ method: "dropCollection", args });
|
||||
this.collections.delete(args.collection_name);
|
||||
delete this.store[args.collection_name];
|
||||
}
|
||||
|
||||
async insert(args: any) {
|
||||
this.calls.push({ method: "insert", args });
|
||||
this.checkDims(args.collection_name, args.data);
|
||||
const col = (this.store[args.collection_name] =
|
||||
this.store[args.collection_name] || {});
|
||||
for (const row of args.data) col[String(row.id)] = row;
|
||||
}
|
||||
|
||||
async upsert(args: any) {
|
||||
this.calls.push({ method: "upsert", args });
|
||||
this.checkDims(args.collection_name, args.data);
|
||||
const col = (this.store[args.collection_name] =
|
||||
this.store[args.collection_name] || {});
|
||||
for (const row of args.data) col[String(row.id)] = row;
|
||||
}
|
||||
|
||||
async delete(args: any) {
|
||||
this.calls.push({ method: "delete", args });
|
||||
const col = this.store[args.collection_name] || {};
|
||||
for (const id of args.ids) delete col[String(id)];
|
||||
}
|
||||
|
||||
async get(args: any) {
|
||||
this.calls.push({ method: "get", args });
|
||||
const col = this.store[args.collection_name] || {};
|
||||
const data = args.ids.map((id: string) => col[String(id)]).filter(Boolean);
|
||||
return { data };
|
||||
}
|
||||
|
||||
async query(args: any) {
|
||||
this.calls.push({ method: "query", args });
|
||||
const col = this.store[args.collection_name] || {};
|
||||
return { data: Object.values(col).slice(0, args.limit ?? 100) };
|
||||
}
|
||||
|
||||
async search(args: any) {
|
||||
this.calls.push({ method: "search", args });
|
||||
return this.searchResponse;
|
||||
}
|
||||
}
|
||||
|
||||
// Suppress the constructor's fire-and-forget initialize() console noise and the
|
||||
// legacy-collection BM25 warning.
|
||||
beforeAll(() => {
|
||||
jest.spyOn(console, "error").mockImplementation(() => {});
|
||||
jest.spyOn(console, "warn").mockImplementation(() => {});
|
||||
});
|
||||
afterAll(() => {
|
||||
(console.error as jest.Mock).mockRestore?.();
|
||||
(console.warn as jest.Mock).mockRestore?.();
|
||||
});
|
||||
|
||||
describe("Milvus vector store (TS OSS SDK)", () => {
|
||||
function makeStore(client: FakeMilvusClient, overrides: any = {}) {
|
||||
return new Milvus({
|
||||
client,
|
||||
collectionName: "mem0",
|
||||
embeddingModelDims: 3,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
it("creates the collection on initialize when it does not exist", async () => {
|
||||
const client = new FakeMilvusClient();
|
||||
const store = makeStore(client);
|
||||
await store.initialize();
|
||||
|
||||
const created = client.calls.find((c) => c.method === "createCollection");
|
||||
expect(created).toBeDefined();
|
||||
expect(created!.args.collection_name).toBe("mem0");
|
||||
const vectorField = created!.args.fields.find(
|
||||
(f: any) => f.name === "vectors",
|
||||
);
|
||||
expect(vectorField.dim).toBe(3);
|
||||
// AUTOINDEX dense index with the default metric (L2, matching the Python provider).
|
||||
expect(created!.args.index_params[0].metric_type).toBe("L2");
|
||||
});
|
||||
|
||||
it("does not recreate an existing collection", async () => {
|
||||
const client = new FakeMilvusClient({ existing: ["mem0"] });
|
||||
const store = makeStore(client);
|
||||
await store.initialize();
|
||||
expect(client.calls.some((c) => c.method === "createCollection")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("inserts records mapping payloads into the metadata field", async () => {
|
||||
const client = new FakeMilvusClient({ existing: ["mem0"] });
|
||||
const store = makeStore(client);
|
||||
await store.initialize();
|
||||
|
||||
await store.insert(
|
||||
[
|
||||
[0.1, 0.2, 0.3],
|
||||
[0.4, 0.5, 0.6],
|
||||
],
|
||||
["a", "b"],
|
||||
[
|
||||
{ data: "first", user_id: "u1" },
|
||||
{ data: "second", user_id: "u1" },
|
||||
],
|
||||
);
|
||||
|
||||
const insertCall = client.calls.find((c) => c.method === "insert")!;
|
||||
expect(insertCall.args.data).toHaveLength(2);
|
||||
expect(insertCall.args.data[0]).toEqual({
|
||||
id: "a",
|
||||
vectors: [0.1, 0.2, 0.3],
|
||||
metadata: { data: "first", user_id: "u1" },
|
||||
});
|
||||
});
|
||||
|
||||
it("round-trips a stored record through get()", async () => {
|
||||
const client = new FakeMilvusClient({ existing: ["mem0"] });
|
||||
const store = makeStore(client);
|
||||
await store.initialize();
|
||||
await store.insert([[1, 0, 0]], ["x"], [{ data: "hello" }]);
|
||||
|
||||
const got = await store.get("x");
|
||||
expect(got).not.toBeNull();
|
||||
expect(got!.id).toBe("x");
|
||||
expect(got!.payload).toEqual({ data: "hello" });
|
||||
|
||||
const missing = await store.get("nope");
|
||||
expect(missing).toBeNull();
|
||||
});
|
||||
|
||||
it("updates a record in place via upsert", async () => {
|
||||
const client = new FakeMilvusClient({ existing: ["mem0"] });
|
||||
const store = makeStore(client);
|
||||
await store.initialize();
|
||||
await store.insert([[1, 0, 0]], ["a"], [{ data: "old" }]);
|
||||
|
||||
await store.update("a", [0, 1, 0], { data: "new" });
|
||||
|
||||
const upsertCall = client.calls.find((c) => c.method === "upsert")!;
|
||||
expect(upsertCall.args.data[0]).toEqual({
|
||||
id: "a",
|
||||
vectors: [0, 1, 0],
|
||||
metadata: { data: "new" },
|
||||
});
|
||||
const got = await store.get("a");
|
||||
expect(got!.payload).toEqual({ data: "new" });
|
||||
});
|
||||
|
||||
it("builds an AND-combined equality filter expression for search", async () => {
|
||||
const client = new FakeMilvusClient({ existing: ["mem0"] });
|
||||
client.searchResponse = {
|
||||
results: [{ id: "a", score: 0.9, metadata: { data: "first" } }],
|
||||
};
|
||||
// Pin a non-L2 metric so the raw score passes through unnormalised.
|
||||
const store = makeStore(client, { metricType: "COSINE" });
|
||||
await store.initialize();
|
||||
|
||||
const res = await store.search([0.1, 0.2, 0.3], 5, {
|
||||
user_id: "u1",
|
||||
agent_id: 7 as any,
|
||||
});
|
||||
|
||||
const searchCall = client.calls.find((c) => c.method === "search")!;
|
||||
expect(searchCall.args.filter).toBe(
|
||||
'(metadata["user_id"] == "u1") and (metadata["agent_id"] == 7)',
|
||||
);
|
||||
expect(searchCall.args.limit).toBe(5);
|
||||
expect(res[0]).toEqual({
|
||||
id: "a",
|
||||
payload: { data: "first" },
|
||||
score: 0.9,
|
||||
});
|
||||
});
|
||||
|
||||
it("normalises L2 distances into a 0..1 similarity score", async () => {
|
||||
const client = new FakeMilvusClient({ existing: ["mem0"] });
|
||||
client.searchResponse = {
|
||||
results: [{ id: "a", score: 3.0, metadata: {} }],
|
||||
};
|
||||
const store = makeStore(client, { metricType: "L2" });
|
||||
await store.initialize();
|
||||
|
||||
const res = await store.search([0, 0, 1], 1);
|
||||
// 1 / (1 + 3) = 0.25
|
||||
expect(res[0].score).toBeCloseTo(0.25, 6);
|
||||
});
|
||||
|
||||
it("escapes embedded quotes in string filter values", async () => {
|
||||
const client = new FakeMilvusClient({ existing: ["mem0"] });
|
||||
const store = makeStore(client);
|
||||
await store.initialize();
|
||||
await store.list({ data: 'a"b' });
|
||||
const queryCall = client.calls.filter((c) => c.method === "query").pop()!;
|
||||
expect(queryCall.args.filter).toBe('(metadata["data"] == "a\\"b")');
|
||||
});
|
||||
|
||||
it("escapes backslashes in string filter values", async () => {
|
||||
const client = new FakeMilvusClient({ existing: ["mem0"] });
|
||||
const store = makeStore(client);
|
||||
await store.initialize();
|
||||
// Value is a\b (one backslash); it must be doubled so the expression
|
||||
// stays well-formed and the backslash can't escape the closing quote.
|
||||
await store.list({ data: "a\\b" });
|
||||
const queryCall = client.calls.filter((c) => c.method === "query").pop()!;
|
||||
expect(queryCall.args.filter).toBe('(metadata["data"] == "a\\\\b")');
|
||||
});
|
||||
|
||||
it("rejects filter keys that are not safe identifiers", async () => {
|
||||
const client = new FakeMilvusClient({ existing: ["mem0"] });
|
||||
const store = makeStore(client);
|
||||
await store.initialize();
|
||||
await expect(
|
||||
store.list({ 'x"] or true or ["y': "z" } as any),
|
||||
).rejects.toThrow(/Invalid filter key/);
|
||||
});
|
||||
|
||||
it("lists records and returns the count", async () => {
|
||||
const client = new FakeMilvusClient({ existing: ["mem0"] });
|
||||
const store = makeStore(client);
|
||||
await store.initialize();
|
||||
await store.insert(
|
||||
[
|
||||
[1, 0, 0],
|
||||
[0, 1, 0],
|
||||
],
|
||||
["a", "b"],
|
||||
[{ data: "x" }, { data: "y" }],
|
||||
);
|
||||
|
||||
const [results, count] = await store.list(undefined, 100);
|
||||
expect(count).toBe(2);
|
||||
expect(results.map((r) => r.id).sort()).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("deletes a record by id", async () => {
|
||||
const client = new FakeMilvusClient({ existing: ["mem0"] });
|
||||
const store = makeStore(client);
|
||||
await store.initialize();
|
||||
await store.insert([[1, 0, 0]], ["a"], [{ data: "x" }]);
|
||||
await store.delete("a");
|
||||
const got = await store.get("a");
|
||||
expect(got).toBeNull();
|
||||
});
|
||||
|
||||
it("generates and persists a user id, then reads it back", async () => {
|
||||
const client = new FakeMilvusClient({ existing: ["mem0"] });
|
||||
const store = makeStore(client);
|
||||
await store.initialize();
|
||||
|
||||
const created = await store.getUserId();
|
||||
expect(typeof created).toBe("string");
|
||||
expect(created.length).toBeGreaterThan(0);
|
||||
|
||||
const readBack = await store.getUserId();
|
||||
expect(readBack).toBe(created);
|
||||
});
|
||||
|
||||
it("setUserId overwrites in place instead of appending rows", async () => {
|
||||
const client = new FakeMilvusClient();
|
||||
const store = makeStore(client);
|
||||
await store.initialize();
|
||||
|
||||
await store.setUserId("user-1");
|
||||
await store.setUserId("user-2");
|
||||
|
||||
// A fresh insert per call would leave two rows; the reuse-id upsert keeps one.
|
||||
const all = await client.query({
|
||||
collection_name: "memory_migrations",
|
||||
limit: 100,
|
||||
});
|
||||
expect(all.data).toHaveLength(1);
|
||||
expect(all.data[0].user_id).toBe("user-2");
|
||||
expect(await store.getUserId()).toBe("user-2");
|
||||
});
|
||||
|
||||
it("creates the telemetry collection with a Milvus-valid vector dim (>= 2)", async () => {
|
||||
// Regression: the helper collection previously used dim 1, which a real
|
||||
// Milvus server rejects (valid range is 2~32768), silently breaking
|
||||
// getUserId/setUserId. The fake now enforces the same bound, so a dim < 2
|
||||
// would throw here instead of passing as it did against the old mock.
|
||||
const client = new FakeMilvusClient({ existing: ["mem0"] });
|
||||
const store = makeStore(client);
|
||||
await store.initialize();
|
||||
await store.getUserId();
|
||||
|
||||
const migCreate = client.calls.find(
|
||||
(c) =>
|
||||
c.method === "createCollection" &&
|
||||
c.args.collection_name === "memory_migrations",
|
||||
)!;
|
||||
expect(migCreate).toBeDefined();
|
||||
const vectorField = migCreate.args.fields.find(
|
||||
(f: any) => f.name === "vectors",
|
||||
);
|
||||
expect(vectorField.dim).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("keywordSearch returns null on a collection without the BM25 schema", async () => {
|
||||
const client = new FakeMilvusClient({ existing: ["mem0"] });
|
||||
const store = makeStore(client);
|
||||
await store.initialize();
|
||||
expect(await store.keywordSearch("hello")).toBeNull();
|
||||
});
|
||||
|
||||
it("creates a BM25 hybrid schema on a fresh collection", async () => {
|
||||
const client = new FakeMilvusClient();
|
||||
const store = makeStore(client);
|
||||
await store.initialize();
|
||||
|
||||
const created = client.calls.find((c) => c.method === "createCollection")!;
|
||||
const fieldNames = created.args.fields.map((f: any) => f.name);
|
||||
expect(fieldNames).toEqual(
|
||||
expect.arrayContaining(["id", "vectors", "metadata", "text", "sparse"]),
|
||||
);
|
||||
// The text field must have the analyzer enabled for BM25 tokenization.
|
||||
const textField = created.args.fields.find((f: any) => f.name === "text");
|
||||
expect(textField.enable_analyzer).toBe(true);
|
||||
// BM25 function maps text -> sparse.
|
||||
expect(created.args.functions[0].input_field_names).toEqual(["text"]);
|
||||
expect(created.args.functions[0].output_field_names).toEqual(["sparse"]);
|
||||
// Sparse BM25 index sits alongside the dense vector index.
|
||||
const sparseIdx = created.args.index_params.find(
|
||||
(i: any) => i.field_name === "sparse",
|
||||
);
|
||||
expect(sparseIdx.index_type).toBe("SPARSE_INVERTED_INDEX");
|
||||
expect(sparseIdx.metric_type).toBe("BM25");
|
||||
});
|
||||
|
||||
it("populates the BM25 text field from payload on a fresh collection", async () => {
|
||||
const client = new FakeMilvusClient();
|
||||
const store = makeStore(client);
|
||||
await store.initialize();
|
||||
|
||||
// Prefers the lemmatized text when present.
|
||||
await store.insert(
|
||||
[[0.1, 0.2, 0.3]],
|
||||
["a"],
|
||||
[{ data: "hello world", text_lemmatized: "hello world lemma" }],
|
||||
);
|
||||
// Falls back to raw data when there is no lemmatized text.
|
||||
await store.insert([[0.4, 0.5, 0.6]], ["b"], [{ data: "just data" }]);
|
||||
|
||||
const insertCalls = client.calls.filter((c) => c.method === "insert");
|
||||
expect(insertCalls[0].args.data[0].text).toBe("hello world lemma");
|
||||
expect(insertCalls[1].args.data[0].text).toBe("just data");
|
||||
});
|
||||
|
||||
it("writes the BM25 text field on update for a BM25 collection", async () => {
|
||||
const client = new FakeMilvusClient();
|
||||
const store = makeStore(client);
|
||||
await store.initialize();
|
||||
await store.insert([[1, 0, 0]], ["a"], [{ data: "old" }]);
|
||||
|
||||
await store.update("a", [0, 1, 0], { data: "new" });
|
||||
|
||||
const upsertCall = client.calls.filter((c) => c.method === "upsert").pop()!;
|
||||
expect(upsertCall.args.data[0].text).toBe("new");
|
||||
expect(upsertCall.args.data[0].metadata).toEqual({ data: "new" });
|
||||
});
|
||||
|
||||
it("names the dense anns_field when searching a BM25 collection", async () => {
|
||||
const client = new FakeMilvusClient();
|
||||
client.searchResponse = {
|
||||
results: [{ id: "a", score: 0.5, metadata: {} }],
|
||||
};
|
||||
const store = makeStore(client, { metricType: "COSINE" });
|
||||
await store.initialize();
|
||||
|
||||
await store.search([0.1, 0.2, 0.3], 3);
|
||||
const searchCall = client.calls.find((c) => c.method === "search")!;
|
||||
expect(searchCall.args.anns_field).toBe("vectors");
|
||||
});
|
||||
|
||||
it("omits anns_field when searching a legacy dense-only collection", async () => {
|
||||
const client = new FakeMilvusClient({ existing: ["mem0"] });
|
||||
client.searchResponse = { results: [] };
|
||||
const store = makeStore(client, { metricType: "COSINE" });
|
||||
await store.initialize();
|
||||
|
||||
await store.search([0.1, 0.2, 0.3], 3);
|
||||
const searchCall = client.calls.find((c) => c.method === "search")!;
|
||||
expect(searchCall.args.anns_field).toBeUndefined();
|
||||
});
|
||||
|
||||
it("runs a BM25 keyword search over the sparse field and parses hits", async () => {
|
||||
const client = new FakeMilvusClient();
|
||||
client.searchResponse = {
|
||||
results: [{ id: "a", score: 4.2, metadata: { data: "kw hit" } }],
|
||||
};
|
||||
// COSINE so the BM25 score passes through unnormalised for a clean assert.
|
||||
const store = makeStore(client, { metricType: "COSINE" });
|
||||
await store.initialize();
|
||||
|
||||
const res = await store.keywordSearch("hello", 7, { user_id: "u1" });
|
||||
|
||||
const searchCall = client.calls.filter((c) => c.method === "search").pop()!;
|
||||
expect(searchCall.args.data).toEqual(["hello"]); // raw text, not a vector
|
||||
expect(searchCall.args.anns_field).toBe("sparse");
|
||||
expect(searchCall.args.limit).toBe(7);
|
||||
expect(searchCall.args.filter).toBe('(metadata["user_id"] == "u1")');
|
||||
expect(res).not.toBeNull();
|
||||
expect(res![0]).toEqual({
|
||||
id: "a",
|
||||
payload: { data: "kw hit" },
|
||||
score: 4.2,
|
||||
});
|
||||
});
|
||||
|
||||
it("detects the BM25 schema on a pre-existing collection via describeCollection", async () => {
|
||||
const client = new FakeMilvusClient({ bm25: ["mem0"] });
|
||||
client.searchResponse = { results: [] };
|
||||
const store = makeStore(client, { metricType: "COSINE" });
|
||||
await store.initialize();
|
||||
|
||||
// Detected as BM25: keywordSearch runs (returns []) instead of null, and
|
||||
// insert writes the text field.
|
||||
expect(await store.keywordSearch("q")).not.toBeNull();
|
||||
await store.insert([[1, 2, 3]], ["a"], [{ data: "d" }]);
|
||||
const insertCall = client.calls.find((c) => c.method === "insert")!;
|
||||
expect(insertCall.args.data[0].text).toBe("d");
|
||||
});
|
||||
});
|
||||
@@ -56,6 +56,7 @@ import { CassandraDB } from "../vector_stores/cassandra";
|
||||
import { PineconeDB } from "../vector_stores/pinecone";
|
||||
import { S3Vectors } from "../vector_stores/s3_vectors";
|
||||
import { TurbopufferDB } from "../vector_stores/turbopuffer";
|
||||
import { Milvus } from "../vector_stores/milvus";
|
||||
import { MongoDB } from "../vector_stores/mongodb";
|
||||
|
||||
export class EmbedderFactory {
|
||||
@@ -172,6 +173,8 @@ export class VectorStoreFactory {
|
||||
return new S3Vectors(config as any);
|
||||
case "turbopuffer":
|
||||
return new TurbopufferDB(config as any);
|
||||
case "milvus":
|
||||
return new Milvus(config as any);
|
||||
case "mongodb":
|
||||
return new MongoDB(config as any);
|
||||
default:
|
||||
|
||||
@@ -0,0 +1,510 @@
|
||||
import { VectorStore } from "./base";
|
||||
import { SearchFilters, VectorStoreConfig, VectorStoreResult } from "../types";
|
||||
|
||||
/**
|
||||
* Supported Milvus metric types. Mirrors the Python provider
|
||||
* (`mem0/configs/vector_stores/milvus.py`).
|
||||
*/
|
||||
export type MilvusMetricType = "L2" | "IP" | "COSINE" | "HAMMING" | "JACCARD";
|
||||
|
||||
export interface MilvusConfig extends VectorStoreConfig {
|
||||
/**
|
||||
* Full URL/address for the Milvus or Zilliz server.
|
||||
* Defaults to `http://localhost:19530`.
|
||||
*/
|
||||
url?: string;
|
||||
/** Token / API key for Zilliz Cloud. Optional for a local setup. */
|
||||
token?: string;
|
||||
/** Name of the database. Optional (Milvus default database when empty). */
|
||||
dbName?: string;
|
||||
/** Collection name. Defaults to `mem0`. */
|
||||
collectionName?: string;
|
||||
/** Embedding dimensionality. Defaults to 1536 (OpenAI). */
|
||||
embeddingModelDims?: number;
|
||||
dimension?: number;
|
||||
/** Similarity metric. Defaults to `L2` (matches the Python provider). */
|
||||
metricType?: MilvusMetricType;
|
||||
/**
|
||||
* Pre-constructed `MilvusClient` instance. When provided, `url`/`token`/`dbName`
|
||||
* are ignored. Primarily useful for dependency injection in tests.
|
||||
*/
|
||||
client?: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Milvus vector store provider for the TypeScript OSS SDK.
|
||||
*
|
||||
* Mirrors the Python provider in `mem0/vector_stores/milvus.py`: dense-vector
|
||||
* CRUD (insert / search / get / update / delete / list + user-id helpers) plus
|
||||
* BM25 hybrid keyword search. New collections are created with a `text` +
|
||||
* `sparse` field pair and a BM25 function so `keywordSearch` can run full-text
|
||||
* search; collections created before BM25 support keep working with it disabled.
|
||||
*
|
||||
* The `@zilliz/milvus2-sdk-node` dependency is lazily required so the package
|
||||
* remains optional. Importing this module never forces the SDK to be installed
|
||||
* until a Milvus store is actually constructed.
|
||||
*/
|
||||
export class Milvus implements VectorStore {
|
||||
private client: any;
|
||||
private readonly collectionName: string;
|
||||
private readonly dimension: number;
|
||||
private readonly metricType: MilvusMetricType;
|
||||
private _initPromise?: Promise<void>;
|
||||
// Lazily-resolved SDK enums (set during client construction).
|
||||
private DataType: any;
|
||||
private FunctionType: any;
|
||||
// Whether this collection has the `text` + `sparse` fields for BM25 hybrid
|
||||
// search. Collections created before BM25 support lack them, so writing a
|
||||
// top-level `text` field or passing a sparse anns_field would be rejected.
|
||||
private hasBm25Schema = false;
|
||||
|
||||
constructor(config: MilvusConfig) {
|
||||
this.collectionName = config.collectionName || "mem0";
|
||||
this.dimension = config.embeddingModelDims || config.dimension || 1536;
|
||||
this.metricType = config.metricType || "L2";
|
||||
|
||||
if (config.client) {
|
||||
this.client = config.client;
|
||||
// Best-effort SDK enum resolution for an injected client (undefined when
|
||||
// the SDK isn't installed, e.g. unit tests that pass a fake client).
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const sdk = require("@zilliz/milvus2-sdk-node");
|
||||
this.DataType = sdk.DataType;
|
||||
this.FunctionType = sdk.FunctionType;
|
||||
} catch (_) {
|
||||
this.DataType = undefined;
|
||||
this.FunctionType = undefined;
|
||||
}
|
||||
} else {
|
||||
let MilvusClient: any;
|
||||
let DataType: any;
|
||||
let FunctionType: any;
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const sdk = require("@zilliz/milvus2-sdk-node");
|
||||
MilvusClient = sdk.MilvusClient;
|
||||
DataType = sdk.DataType;
|
||||
FunctionType = sdk.FunctionType;
|
||||
} catch (_) {
|
||||
throw new Error(
|
||||
"The '@zilliz/milvus2-sdk-node' package is required to use the Milvus vector store. " +
|
||||
"Install it with: npm install @zilliz/milvus2-sdk-node",
|
||||
);
|
||||
}
|
||||
this.DataType = DataType;
|
||||
this.FunctionType = FunctionType;
|
||||
this.client = new MilvusClient({
|
||||
address: config.url || "http://localhost:19530",
|
||||
token: config.token,
|
||||
database: config.dbName || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
this.initialize().catch((err) =>
|
||||
console.error("Error initializing Milvus:", err),
|
||||
);
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
if (!this._initPromise) {
|
||||
this._initPromise = this.createCol(this.collectionName, this.dimension);
|
||||
}
|
||||
return this._initPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the collection if it does not already exist, with an AUTOINDEX
|
||||
* dense-vector index plus a BM25 `text` -> `sparse` full-text index. Idempotent
|
||||
* (mirrors the Python `create_col`). When the collection already exists, detect
|
||||
* whether the BM25 `text`/`sparse` fields are present so keyword search
|
||||
* degrades gracefully on collections created before BM25 support.
|
||||
*/
|
||||
private async createCol(
|
||||
collectionName: string,
|
||||
vectorSize: number,
|
||||
): Promise<void> {
|
||||
const has = await this.client.hasCollection({
|
||||
collection_name: collectionName,
|
||||
});
|
||||
// milvus2-sdk-node returns { value: boolean } for hasCollection.
|
||||
const exists = typeof has === "object" && has !== null ? has.value : has;
|
||||
if (exists) {
|
||||
// A pre-existing collection may predate BM25 support. Inspect its schema
|
||||
// so insert/search/keywordSearch know whether the text + sparse fields
|
||||
// exist instead of assuming and getting rejected by the server.
|
||||
const desc = await this.client.describeCollection({
|
||||
collection_name: collectionName,
|
||||
});
|
||||
const names = new Set(
|
||||
(desc?.schema?.fields || []).map((f: any) => f.name),
|
||||
);
|
||||
this.hasBm25Schema = names.has("text") && names.has("sparse");
|
||||
if (!this.hasBm25Schema) {
|
||||
console.warn(
|
||||
`Milvus collection '${collectionName}' predates BM25 hybrid search ` +
|
||||
"(no 'text'/'sparse' fields). Keyword scoring is disabled for it; " +
|
||||
"semantic search still works. Use a fresh collection to enable it.",
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const DataType = this.DataType || {};
|
||||
const fields = [
|
||||
{
|
||||
name: "id",
|
||||
data_type: DataType.VarChar,
|
||||
is_primary_key: true,
|
||||
max_length: 512,
|
||||
},
|
||||
{
|
||||
name: "vectors",
|
||||
data_type: DataType.FloatVector,
|
||||
dim: vectorSize,
|
||||
},
|
||||
{
|
||||
name: "metadata",
|
||||
data_type: DataType.JSON,
|
||||
},
|
||||
// Analyzer-enabled text field that feeds the BM25 function below.
|
||||
{
|
||||
name: "text",
|
||||
data_type: DataType.VarChar,
|
||||
max_length: 65535,
|
||||
enable_analyzer: true,
|
||||
},
|
||||
// Sparse vectors are generated automatically by the BM25 function.
|
||||
{
|
||||
name: "sparse",
|
||||
data_type: DataType.SparseFloatVector,
|
||||
},
|
||||
];
|
||||
|
||||
await this.client.createCollection({
|
||||
collection_name: collectionName,
|
||||
fields,
|
||||
enable_dynamic_field: true,
|
||||
// BM25 turns the `text` field into `sparse` vectors for full-text search.
|
||||
functions: [
|
||||
{
|
||||
name: "bm25",
|
||||
type: this.FunctionType?.BM25,
|
||||
input_field_names: ["text"],
|
||||
output_field_names: ["sparse"],
|
||||
params: {},
|
||||
},
|
||||
],
|
||||
index_params: [
|
||||
{
|
||||
field_name: "vectors",
|
||||
index_type: "AUTOINDEX",
|
||||
metric_type: this.metricType,
|
||||
index_name: "vector_index",
|
||||
},
|
||||
{
|
||||
field_name: "sparse",
|
||||
index_type: "SPARSE_INVERTED_INDEX",
|
||||
metric_type: "BM25",
|
||||
index_name: "sparse_index",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await this.client.loadCollection({ collection_name: collectionName });
|
||||
this.hasBm25Schema = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter keys are interpolated straight into the expression, so restrict them
|
||||
* to safe identifiers (same rule as the Python provider) to block injection.
|
||||
*/
|
||||
private static readonly SAFE_FILTER_KEY = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
||||
|
||||
/**
|
||||
* Build a Milvus boolean filter expression from a flat filters object.
|
||||
* Mirrors the Python `_create_filter` (equality only, AND-combined): validate
|
||||
* each key, escape string values (backslash first, then double-quote), and
|
||||
* reject value types Milvus can't compare against a scalar field.
|
||||
*/
|
||||
private createFilter(filters?: SearchFilters): string | undefined {
|
||||
if (!filters || Object.keys(filters).length === 0) return undefined;
|
||||
const operands: string[] = [];
|
||||
for (const [key, value] of Object.entries(filters)) {
|
||||
if (value === undefined || value === null) continue;
|
||||
if (!Milvus.SAFE_FILTER_KEY.test(key)) {
|
||||
throw new Error(`Invalid filter key: ${JSON.stringify(key)}`);
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
// Escape backslashes before quotes so a value can't break out of the
|
||||
// string literal (order matters, exactly as in the Python provider).
|
||||
const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||
operands.push(`(metadata["${key}"] == "${escaped}")`);
|
||||
} else if (typeof value === "number" || typeof value === "boolean") {
|
||||
operands.push(`(metadata["${key}"] == ${value})`);
|
||||
} else {
|
||||
throw new Error(
|
||||
`Filter value for ${JSON.stringify(key)} must be a string, number, or boolean, got ${typeof value}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return operands.length > 0 ? operands.join(" and ") : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Text fed to the BM25 sparse index for a payload. Prefers the lemmatized
|
||||
* text, falls back to the raw memory `data`, and truncates to the VarChar
|
||||
* limit (mirrors the Python provider).
|
||||
*/
|
||||
private bm25Text(payload?: Record<string, any>): string {
|
||||
if (!payload) return "";
|
||||
const raw = payload.text_lemmatized || payload.data || "";
|
||||
return String(raw).slice(0, 65535);
|
||||
}
|
||||
|
||||
async insert(
|
||||
vectors: number[][],
|
||||
ids: string[],
|
||||
payloads: Record<string, any>[],
|
||||
): Promise<void> {
|
||||
const data = vectors.map((vector, idx) => {
|
||||
const metadata = payloads[idx] || {};
|
||||
const row: Record<string, any> = {
|
||||
id: ids[idx],
|
||||
vectors: vector,
|
||||
metadata,
|
||||
};
|
||||
// Only write `text` when the collection has the BM25 schema; legacy
|
||||
// collections reject an unknown top-level field.
|
||||
if (this.hasBm25Schema) row.text = this.bm25Text(metadata);
|
||||
return row;
|
||||
});
|
||||
await this.client.insert({
|
||||
collection_name: this.collectionName,
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Map raw Milvus search hits to VectorStoreResult. For the L2 metric,
|
||||
* distances are unbounded and smaller-is-better, so normalise them to a 0..1
|
||||
* similarity; every other metric passes the raw score through. Shared by
|
||||
* search and keywordSearch so both match the Python provider's `_parse_output`
|
||||
* (which likewise normalises by metric type regardless of dense vs BM25 score).
|
||||
*/
|
||||
private parseHits(hits: any[]): VectorStoreResult[] {
|
||||
return hits.map((hit: any) => {
|
||||
const rawDistance = hit.score ?? hit.distance;
|
||||
let score = rawDistance;
|
||||
if (rawDistance != null && this.metricType === "L2") {
|
||||
score = 1.0 / (1.0 + rawDistance);
|
||||
}
|
||||
return {
|
||||
id: String(hit.id),
|
||||
payload: hit.metadata || {},
|
||||
score,
|
||||
} as VectorStoreResult;
|
||||
});
|
||||
}
|
||||
|
||||
async search(
|
||||
query: number[],
|
||||
topK: number = 5,
|
||||
filters?: SearchFilters,
|
||||
): Promise<VectorStoreResult[]> {
|
||||
const filter = this.createFilter(filters);
|
||||
const req: Record<string, any> = {
|
||||
collection_name: this.collectionName,
|
||||
data: [query],
|
||||
limit: topK,
|
||||
filter,
|
||||
output_fields: ["*"],
|
||||
};
|
||||
// A BM25 collection has both a dense `vectors` and a sparse `sparse` field,
|
||||
// so anns_field is ambiguous unless we name the dense one explicitly.
|
||||
if (this.hasBm25Schema) req.anns_field = "vectors";
|
||||
const res = await this.client.search(req);
|
||||
return this.parseHits(res?.results || []);
|
||||
}
|
||||
|
||||
/**
|
||||
* BM25 full-text keyword search over the sparse field. Milvus tokenizes the
|
||||
* raw query string via the collection's BM25 function. Returns null when the
|
||||
* collection has no BM25 schema so callers fall back to dense search only
|
||||
* (mirrors the Python provider).
|
||||
*/
|
||||
async keywordSearch(
|
||||
query: string,
|
||||
topK: number = 5,
|
||||
filters?: SearchFilters,
|
||||
): Promise<VectorStoreResult[] | null> {
|
||||
if (!this.hasBm25Schema) return null;
|
||||
try {
|
||||
const filter = this.createFilter(filters);
|
||||
const res = await this.client.search({
|
||||
collection_name: this.collectionName,
|
||||
data: [query],
|
||||
anns_field: "sparse",
|
||||
limit: topK,
|
||||
filter,
|
||||
output_fields: ["*"],
|
||||
});
|
||||
return this.parseHits(res?.results || []);
|
||||
} catch (_) {
|
||||
// Keyword search is best-effort; degrade to null rather than failing the
|
||||
// whole retrieval path.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async get(vectorId: string): Promise<VectorStoreResult | null> {
|
||||
const res = await this.client.get({
|
||||
collection_name: this.collectionName,
|
||||
ids: [vectorId],
|
||||
output_fields: ["id", "metadata"],
|
||||
});
|
||||
const rows = res?.data || [];
|
||||
if (!rows.length) return null;
|
||||
return {
|
||||
id: String(rows[0].id),
|
||||
payload: rows[0].metadata || {},
|
||||
};
|
||||
}
|
||||
|
||||
async update(
|
||||
vectorId: string,
|
||||
vector: number[],
|
||||
payload: Record<string, any>,
|
||||
): Promise<void> {
|
||||
const row: Record<string, any> = {
|
||||
id: vectorId,
|
||||
vectors: vector,
|
||||
metadata: payload,
|
||||
};
|
||||
if (this.hasBm25Schema) row.text = this.bm25Text(payload);
|
||||
await this.client.upsert({
|
||||
collection_name: this.collectionName,
|
||||
data: [row],
|
||||
});
|
||||
}
|
||||
|
||||
async delete(vectorId: string): Promise<void> {
|
||||
await this.client.delete({
|
||||
collection_name: this.collectionName,
|
||||
ids: [vectorId],
|
||||
});
|
||||
}
|
||||
|
||||
async deleteCol(): Promise<void> {
|
||||
await this.client.dropCollection({
|
||||
collection_name: this.collectionName,
|
||||
});
|
||||
}
|
||||
|
||||
async list(
|
||||
filters?: SearchFilters,
|
||||
topK: number = 100,
|
||||
): Promise<[VectorStoreResult[], number]> {
|
||||
const filter = this.createFilter(filters);
|
||||
const res = await this.client.query({
|
||||
collection_name: this.collectionName,
|
||||
filter: filter ?? "",
|
||||
limit: topK,
|
||||
output_fields: ["id", "metadata"],
|
||||
});
|
||||
const rows = res?.data || [];
|
||||
const results: VectorStoreResult[] = rows.map((row: any) => ({
|
||||
id: String(row.id),
|
||||
payload: row.metadata || {},
|
||||
}));
|
||||
return [results, results.length];
|
||||
}
|
||||
|
||||
private generateUUID(): string {
|
||||
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = c === "x" ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
private async ensureMigrationsCol(): Promise<void> {
|
||||
const name = "memory_migrations";
|
||||
const has = await this.client.hasCollection({ collection_name: name });
|
||||
const exists = typeof has === "object" && has !== null ? has.value : has;
|
||||
if (exists) return;
|
||||
|
||||
const DataType = this.DataType || {};
|
||||
await this.client.createCollection({
|
||||
collection_name: name,
|
||||
fields: [
|
||||
{
|
||||
name: "id",
|
||||
data_type: DataType.VarChar,
|
||||
is_primary_key: true,
|
||||
max_length: 512,
|
||||
},
|
||||
// Milvus rejects a FloatVector with dim < 2, so use the minimum (2). This
|
||||
// helper collection is never vector-searched; the vector is a fixed
|
||||
// placeholder that exists only to satisfy the required-vector-field schema.
|
||||
{ name: "vectors", data_type: DataType.FloatVector, dim: 2 },
|
||||
{ name: "user_id", data_type: DataType.VarChar, max_length: 512 },
|
||||
],
|
||||
index_params: [
|
||||
{
|
||||
field_name: "vectors",
|
||||
index_type: "AUTOINDEX",
|
||||
// Fixed metric: this helper collection is never vector-searched, and a
|
||||
// zero vector has no direction, so COSINE would be degenerate here.
|
||||
metric_type: "L2",
|
||||
index_name: "vector_index",
|
||||
},
|
||||
],
|
||||
});
|
||||
await this.client.loadCollection({ collection_name: name });
|
||||
}
|
||||
|
||||
async getUserId(): Promise<string> {
|
||||
await this.ensureMigrationsCol();
|
||||
const res = await this.client.query({
|
||||
collection_name: "memory_migrations",
|
||||
filter: "",
|
||||
limit: 1,
|
||||
output_fields: ["*"],
|
||||
});
|
||||
const rows = res?.data || [];
|
||||
if (rows.length > 0 && rows[0].user_id) {
|
||||
return String(rows[0].user_id);
|
||||
}
|
||||
const randomUserId =
|
||||
Math.random().toString(36).substring(2, 15) +
|
||||
Math.random().toString(36).substring(2, 15);
|
||||
await this.client.insert({
|
||||
collection_name: "memory_migrations",
|
||||
data: [
|
||||
{ id: this.generateUUID(), vectors: [0, 0], user_id: randomUserId },
|
||||
],
|
||||
});
|
||||
return randomUserId;
|
||||
}
|
||||
|
||||
async setUserId(userId: string): Promise<void> {
|
||||
await this.ensureMigrationsCol();
|
||||
// Keep a single row: reuse the existing row's id when present so the upsert
|
||||
// overwrites it in place instead of appending a new row on every call
|
||||
// (mirrors the qdrant provider's single-row telemetry id).
|
||||
const existing = await this.client.query({
|
||||
collection_name: "memory_migrations",
|
||||
filter: "",
|
||||
limit: 1,
|
||||
output_fields: ["id"],
|
||||
});
|
||||
const rows = existing?.data || [];
|
||||
const id =
|
||||
rows.length > 0 && rows[0].id ? String(rows[0].id) : this.generateUUID();
|
||||
await this.client.upsert({
|
||||
collection_name: "memory_migrations",
|
||||
data: [{ id, vectors: [0, 0], user_id: userId }],
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user