feat(ts-sdk): add Milvus vector store provider (closes #5774) (#5889)

Co-authored-by: kartik-mem0 <kartik.labhshetwar@mem0.ai>
This commit is contained in:
Bartok
2026-07-08 20:25:06 +05:30
committed by GitHub
co-authored by kartik-mem0
parent fbdbab805d
commit e72ae96ad4
10 changed files with 1803 additions and 13 deletions
+1
View File
@@ -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";
+517
View File
@@ -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");
});
});
+3
View File
@@ -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:
+510
View File
@@ -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 }],
});
}
}