Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
21 changes: 21 additions & 0 deletions .changeset/mean-pets-perform.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
"@voltagent/core": patch
---

Fix stale semantic-search results after `Memory.clearMessages()`.

Previously, `clearMessages()` removed conversation messages from storage but left vector
embeddings behind when a vector adapter was configured. This meant semantic search could
still return hits for cleared conversations even though the message history had been removed.

## What Changed

- `Memory.clearMessages(userId, conversationId)` now deletes vector entries for that
conversation before clearing storage
- `Memory.clearMessages(userId)` now also deletes vector entries across all of the user's
conversations

## Impact

- Cleared conversations no longer appear in semantic search results
- Message storage and vector storage stay in sync after cleanup
57 changes: 57 additions & 0 deletions packages/core/src/memory/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ const isEmbeddingAdapter = (value: EmbeddingAdapterInput): value is EmbeddingAda
const isEmbeddingAdapterConfig = (value: EmbeddingAdapterInput): value is EmbeddingAdapterConfig =>
typeof value === "object" && value !== null && "model" in value && !isEmbeddingAdapter(value);

const VECTOR_CLEAR_CONVERSATION_PAGE_SIZE = 200;

const resolveEmbeddingAdapter = (
embedding?: EmbeddingAdapterInput,
): EmbeddingAdapter | undefined => {
Expand Down Expand Up @@ -170,6 +172,20 @@ export class Memory {
conversationId?: string,
context?: OperationContext,
): Promise<void> {
if (this.vector) {
try {
const vectorIds = await this.getMessageVectorIdsForClear(userId, conversationId);
if (vectorIds.length > 0) {
await this.vector.deleteBatch(vectorIds);
}
} catch (error) {
console.warn(
`Failed to delete vectors while clearing messages for user ${userId}${conversationId ? ` conversation ${conversationId}` : ""}:`,
error,
);
}
}

return this.storage.clearMessages(userId, conversationId, context);
}

Expand Down Expand Up @@ -376,6 +392,47 @@ export class Memory {
return ordered;
}

private async getMessageVectorIdsForClear(
userId: string,
conversationId?: string,
): Promise<string[]> {
const vectorIds = new Set<string>();

if (conversationId) {
const messages = await this.storage.getMessages(userId, conversationId);
for (const message of messages) {
vectorIds.add(`msg_${conversationId}_${message.id}`);
}
return Array.from(vectorIds);
}

const totalConversations = await this.storage.countConversations({ userId });
let offset = 0;

while (offset < totalConversations) {
const conversations = await this.storage.queryConversations({

@cubic-dev-ai cubic-dev-ai Bot Mar 15, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: This paginated query needs a deterministic unique sort. As written, offset paging over queryConversations() can skip conversations with identical timestamps, leaving some vectors undeleted during user-wide clearMessages().

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/core/src/memory/index.ts, line 412:

<comment>This paginated query needs a deterministic unique sort. As written, offset paging over `queryConversations()` can skip conversations with identical timestamps, leaving some vectors undeleted during user-wide `clearMessages()`.</comment>

<file context>
@@ -404,12 +406,27 @@ export class Memory {
+    let offset = 0;
+
+    while (true) {
+      const conversations = await this.storage.queryConversations({
+        userId,
+        limit: VECTOR_CLEAR_CONVERSATION_PAGE_SIZE,
</file context>
Fix with Cubic

userId,
limit: VECTOR_CLEAR_CONVERSATION_PAGE_SIZE,
offset,
});

for (const conversation of conversations) {
const messages = await this.storage.getMessages(userId, conversation.id);
for (const message of messages) {
vectorIds.add(`msg_${conversation.id}_${message.id}`);
}
}

if (conversations.length === 0) {
break;
}

offset += conversations.length;
}

return Array.from(vectorIds);
}

/**
* Merge two arrays of messages, removing duplicates
*/
Expand Down
113 changes: 113 additions & 0 deletions packages/core/src/memory/semantic-search.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { EmbeddingAdapter } from "./adapters/embedding/types";
import { InMemoryStorageAdapter } from "./adapters/storage/in-memory";
import { InMemoryVectorAdapter } from "./adapters/vector/in-memory";
import { Memory } from "./index";
import type { ConversationQueryOptions } from "./types";

// Mock embedding adapter
class MockEmbeddingAdapter implements EmbeddingAdapter {
Expand All @@ -32,6 +33,22 @@ class MockEmbeddingAdapter implements EmbeddingAdapter {
}
}

class PagedConversationStorageAdapter extends InMemoryStorageAdapter {
constructor(private readonly defaultPageSize: number) {
super();
}

override async queryConversations(options: ConversationQueryOptions) {
const limit = Math.min(options.limit ?? Number.POSITIVE_INFINITY, this.defaultPageSize);

return super.queryConversations({
...options,
limit,
offset: options.offset ?? 0,
});
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

describe("Memory V2 - Semantic Search", () => {
let memory: Memory;
let storage: InMemoryStorageAdapter;
Expand Down Expand Up @@ -455,5 +472,101 @@ describe("Memory V2 - Semantic Search", () => {
expect.arrayContaining([`msg_${conversationId}_msg1`, `msg_${conversationId}_msg2`]),
);
});

it("should delete vectors when conversation messages are cleared", async () => {
const userId = "user123";
const conversationId = "conv789";

await memory.createConversation({
id: conversationId,
userId,
resourceId: "agent1",
title: "Clear Conversation",
});

const messages: UIMessage[] = [
{
id: "msg1",
role: "user",
parts: [{ type: "text", text: "My name is Sujal" }],
},
{
id: "msg2",
role: "assistant",
parts: [{ type: "text", text: "Your name is Sujal." }],
},
];

await memory.addMessages(messages, userId, conversationId);

const beforeClear = await memory.searchSimilar("What is my name?", {
limit: 5,
filter: { userId, conversationId },
});
const deleteBatchSpy = vi.spyOn(vector, "deleteBatch");

await memory.clearMessages(userId, conversationId);

const storedMessages = await memory.getMessages(userId, conversationId);
const afterClear = await memory.searchSimilar("What is my name?", {
limit: 5,
filter: { userId, conversationId },
});

expect(beforeClear.length).toBeGreaterThan(0);
expect(deleteBatchSpy).toHaveBeenCalledWith(
expect.arrayContaining([`msg_${conversationId}_msg1`, `msg_${conversationId}_msg2`]),
);
expect(storedMessages).toHaveLength(0);
expect(afterClear).toHaveLength(0);
});

it("should paginate user-wide vector cleanup across all conversations", async () => {
const userId = "paged-user";
const pagedStorage = new PagedConversationStorageAdapter(2);
const pagedVector = new InMemoryVectorAdapter();
const pagedMemory = new Memory({
storage: pagedStorage,
embedding: new MockEmbeddingAdapter(),
vector: pagedVector,
});

for (const conversationId of ["conv-1", "conv-2", "conv-3"]) {
await pagedMemory.createConversation({
id: conversationId,
userId,
resourceId: "agent1",
title: conversationId,
});

await pagedMemory.addMessage(
{
id: `msg-${conversationId}`,
role: "user",
parts: [{ type: "text", text: `Memory from ${conversationId}` }],
},
userId,
conversationId,
);
}

const beforeClear = await pagedMemory.searchSimilar("Memory from conv-3", {
limit: 5,
filter: { userId },
});

await pagedMemory.clearMessages(userId);

const afterClear = await pagedMemory.searchSimilar("Memory from conv-3", {
limit: 5,
filter: { userId },
});

expect(beforeClear.length).toBeGreaterThan(0);
expect(await pagedMemory.getMessages(userId, "conv-1")).toHaveLength(0);
expect(await pagedMemory.getMessages(userId, "conv-2")).toHaveLength(0);
expect(await pagedMemory.getMessages(userId, "conv-3")).toHaveLength(0);
expect(afterClear).toHaveLength(0);
});
});
});
Loading