Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/clean-vector-search-results.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@voltagent/core": patch
---

Copy embeddings in in-memory vector search results and deep-clone metadata at the store, get and search boundaries so caller mutations do not corrupt stored memories or change subsequent user-filtered searches. Only selected search results are cloned.
66 changes: 66 additions & 0 deletions packages/core/src/memory/adapters/vector/in-memory.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import { InMemoryVectorAdapter } from "./in-memory";

describe("InMemoryVectorAdapter search result isolation", () => {
it.each(["search", "get", "store"])(
"isolates nested metadata at the %s boundary",
async (operation) => {
const adapter = new InMemoryVectorAdapter();
const metadata = { context: { labels: ["original"] } };
await adapter.store("memory", [1, 0], metadata);

if (operation === "store") {
metadata.context.labels.push("changed");
} else {
const result =
operation === "search" ? (await adapter.search([1, 0]))[0] : await adapter.get("memory");
const context = result?.metadata?.context as typeof metadata.context;
context.labels.push("changed");
}

expect((await adapter.get("memory"))?.metadata).toEqual({
context: { labels: ["original"] },
});
},
);

it("does not let a returned embedding corrupt future similarity searches", async () => {
const adapter = new InMemoryVectorAdapter();
await adapter.store("matching", [1, 0], { userId: "alice" });
await adapter.store("other", [0, 1], { userId: "alice" });

const [result] = await adapter.search([1, 0]);
result.vector[0] = 0;
result.vector[1] = 1;

expect((await adapter.get("matching"))?.vector).toEqual([1, 0]);
const [nextResult] = await adapter.search([1, 0]);
expect(nextResult.id).toBe("matching");
expect(nextResult.score).toBe(1);
});

it("does not let returned metadata change user-scoped retrieval", async () => {
const adapter = new InMemoryVectorAdapter();
await adapter.store("memory", [1, 0], { userId: "alice" });

const [result] = await adapter.search([1, 0], { filter: { userId: "alice" } });
if (!result.metadata) throw new Error("Expected stored metadata");
result.metadata.userId = "bob";

expect(await adapter.search([1, 0], { filter: { userId: "bob" } })).toEqual([]);
expect(await adapter.search([1, 0], { filter: { userId: "alice" } })).toHaveLength(1);
expect((await adapter.get("memory"))?.metadata).toEqual({ userId: "alice" });
});

it("supports results without metadata and keeps stored input isolated", async () => {
const adapter = new InMemoryVectorAdapter();
const vector = [1, 0];
await adapter.store("memory", vector);
vector[0] = 0;

const [result] = await adapter.search([1, 0]);
expect(result.vector).toEqual([1, 0]);
expect(result.metadata).toBeUndefined();
expect(result.score).toBe(1);
});
});
11 changes: 8 additions & 3 deletions packages/core/src/memory/adapters/vector/in-memory.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { deepClone } from "@voltagent/internal/utils";
import { cosineSimilarity } from "../../utils/vector-math";
import type { SearchResult, VectorAdapter, VectorItem } from "./types";

Expand Down Expand Up @@ -26,7 +27,7 @@ export class InMemoryVectorAdapter implements VectorAdapter {
this.vectors.set(id, {
id,
vector: [...vector], // Clone to prevent external modifications
metadata: metadata ? { ...metadata } : undefined,
metadata: metadata ? deepClone(metadata) : undefined,
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This used to be { ...metadata }, so nested values came back by reference. Now nested class instances come back as plain objects: structuredClone drops the prototype, and the fallback builds {} at packages/internal/src/utils/objects.ts:66. So instanceof checks and methods on stored metadata stop working. Fine if metadata is meant to be plain data, but it might be worth calling out in the changeset.

});
}

Expand Down Expand Up @@ -83,7 +84,11 @@ export class InMemoryVectorAdapter implements VectorAdapter {
// Sort by score (descending) and limit results
results.sort((a, b) => b.score - a.score);

return results.slice(0, limit);
return results.slice(0, limit).map((item) => ({
...item,
vector: [...item.vector],
metadata: item.metadata ? deepClone(item.metadata) : undefined,
}));
}

async delete(id: string): Promise<void> {
Expand Down Expand Up @@ -115,7 +120,7 @@ export class InMemoryVectorAdapter implements VectorAdapter {
return {
...item,
vector: [...item.vector],
metadata: item.metadata ? { ...item.metadata } : undefined,
metadata: item.metadata ? deepClone(item.metadata) : undefined,
};
}

Expand Down