Skip to content

Fix #2331: purge embeddings when delete_node_by_prams removes memories - #2533

Open
simpleqt wants to merge 1 commit into
MemTensor:mainfrom
simpleqt:fix/delete-purges-vectors
Open

simpleqt wants to merge 1 commit into
MemTensor:mainfrom
simpleqt:fix/delete-purges-vectors

Conversation

@simpleqt

Copy link
Copy Markdown

Fix #2331: purge embeddings when delete_node_by_prams removes memories

Fixes #2331.

Neo4jCommunityGraphDB.delete_node_by_prams() deletes the matching graph nodes
but never purges their embeddings from vec_db. Retrieval reads the vector store
first, so a deleted memory stays retrievable while the delete API still answers
success — the symptom in the report. The sibling paths already do this:
clear() and delete_node_by_mem_cube_id() both purge vec_db, and
delete_node_by_prams is the one that was missed.

delete_node_by_prams(memory_ids=['mem-1','mem-2'])

base:  returned deleted_count = 2
       graph store now        = ['mem-3']
       vec_db now             = ['mem-1', 'mem-2', 'mem-3']   <- stale
       vec_db.delete calls    = 0

fixed: returned deleted_count = 2
       graph store now        = ['mem-3']
       vec_db now             = ['mem-3']
       vec_db.delete calls    = 1

With a filter, the stale points also accumulate: they keep occupying the vector
top-k window, which is how a commenter saw search degrade to zero results after
roughly 520 orphans.

Change

Both implementations of delete_node_by_prams now collect the matching node ids
inside the same session, before the DELETE, and purge them afterwards through a
new _delete_vectors_for_deleted_nodes() helper on Neo4jGraphDB:

  • Failure is logged, not raised. The graph deletion is already committed, so
    a vector-store outage must not turn a successful delete into "Failed to delete
    memories". This matches what clear() already does.
  • The enterprise Neo4jGraphDB has no vec_db (vec_config lives only on
    the community config), so the helper resolves it with getattr(self, "vec_db", None) and degrades to a no-op instead of raising AttributeError.
  • Return value unchanged. The community path now returns the number of ids it
    collected rather than running a separate count(n) query — the same number,
    one query fewer.
  • The count query is replaced by the id-collecting query, so this does not add a
    round trip.

Testing

tests/graph_dbs/test_delete_vec_cleanup.py (new, no Neo4j, Qdrant or network —
the driver and the vector store are fakes):

  • with the test but the source at the base revision: 3 failed, 5 passed —
    assert {'mem-1','mem-2','mem-3'} == {'mem-2'} in the filter path, and the same
    shape for the memory-id path and the return-value assertion
  • with the fix: 8 passed, and tests/graph_dbs → 39 passed, 3 skipped
  • the 5 tests that pass on both are the graceful-degradation guards (no vec_db,
    nothing matched, vector-store failure) which legitimately hold before and after
  • tests/memories tests/api tests/mem_reader → 256 passed, 5 failed, and the same
    5 fail on the untouched base tree (Ollama/environment), so they are pre-existing
  • ruff at the pinned 0.11.8 passes check and format --check on all three
    files; repo-wide format --check reports 631 files already formatted

Callers checked

Every caller of the changed method was grepped and checked for a changed
contract: memories/textual/tree.py:415,435 (ignore the return value),
api/handlers/memory_handler.py:441-462 (the delete API path — returns success
or failure purely on exception, which is exactly what the fix makes truthful),
and mem_reader/read_skill_memory/process_skill_memory.py:1184 (deletes a
replaced memory, now also purged). vec_db.delete accepts an unbounded id list
(vec_dbs/qdrant.py:347, and the base class takes the same list signature), which
the new caller relies on.

Two other methods share this gap — the inherited Neo4jGraphDB.delete_node() and
remove_oldest_memory(), used by L3 maintenance and eviction. They are outside
the delete-memory API path this issue reports, so they are deliberately not
touched here; happy to file that separately if you want it.

AI assistance: this change was produced with AI assistance (an autonomous coding
agent) from the linked issue; the analysis, the reproduction, the before/after
test runs and the lint runs were executed locally, and a human reviewed the
result before submission.

Copilot AI balanced review requested due to automatic review settings October 21, 2026 15:06

Copilot AI left a comment

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

An organization owner can choose the repository organization as the billing source for reviews requested by members with a paid Copilot license. Learn about Copilot code review billing.

@Memtensor-AI Memtensor-AI added area:database graph_db + vector_db | 图数据库与向量数据库 status:in-progress Someone or AI is working on it | 人工或 AI 正在处理 labels Oct 21, 2026
@Memtensor-AI
Memtensor-AI requested a review from wustzdy October 21, 2026 15:15
@Memtensor-AI

Memtensor-AI commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator

🤖 Open Code Review

Target: PR #2533
Task: 08574e7587c390f7
Base: main
Head: fix/delete-purges-vectors

🔍 OpenCodeReview found 4 issue(s) in this PR.


1. src/memos/graph_dbs/neo4j.py (L2021-L2023)

This Cypher query is invalid. After DETACH DELETE n, the variable n is removed from scope and cannot be referenced in RETURN n.id. In Cypher, accessing a deleted node's property after deletion raises a runtime error (Neo.ClientError.Statement.SyntaxError or a runtime exception depending on the driver/version).

The id must be projected into an independent variable before the delete step:

MATCH (n:Memory) WHERE {final_where} WITH n, n.id AS id DETACH DELETE n RETURN id

This way id carries the value independently of n and remains valid after deletion.

💡 Suggested Change

Before:

        delete_query = (
            f"MATCH (n:Memory) WHERE {final_where} WITH n DETACH DELETE n RETURN n.id AS id"
        )

After:

        delete_query = (
            f"MATCH (n:Memory) WHERE {final_where} WITH n, n.id AS id DETACH DELETE n RETURN id"
        )

2. src/memos/graph_dbs/neo4j_community.py (L1043-L1045)

Same invalid Cypher issue as in the parent class: n.id is referenced after DETACH DELETE n, but n is no longer in scope at that point. Project the id before the delete:

MATCH (n:Memory) WHERE {ids_where} WITH n, n.id AS id DETACH DELETE n RETURN id
💡 Suggested Change

Before:

        delete_query = (
            f"MATCH (n:Memory) WHERE {ids_where} WITH n DETACH DELETE n RETURN n.id AS id"
        )

After:

        delete_query = (
            f"MATCH (n:Memory) WHERE {ids_where} WITH n, n.id AS id DETACH DELETE n RETURN id"
        )

3. src/memos/graph_dbs/neo4j.py (L2032-L2036)

There is an inconsistency between how deleted_count and deleted_ids are computed. deleted_ids is built from the RETURN rows (one row per graph node whose n.id is not NULL), while deleted_count comes from summary.counters.nodes_deleted (the actual number of nodes destroyed, including those with a NULL id). If any Memory node lacks an id property, these two numbers will diverge silently: the node is counted in deleted_count but its (NULL) id is included in deleted_ids, potentially causing vec_db.delete to be called with None values.

Since the community subclass already uses len(deleted_ids) as the count, consider aligning both implementations and filtering out NULL ids before calling the vector store:

deleted_ids = [record["id"] for record in result if record["id"] is not None]
summary = result.consume()
deleted_count = summary.counters.nodes_deleted if summary.counters else 0

4. src/memos/graph_dbs/neo4j_community.py (L1052)

If a Memory node has no id property, record["id"] evaluates to None, and those None values will be passed to _delete_vectors_for_deleted_nodes and then to vec_db.delete(). This can cause unexpected failures or silent no-ops in the vector store. Filter out NULL ids:

deleted_ids = [record["id"] for record in session.run(delete_query, **params) if record["id"] is not None]

Generated by cloud-assistant via Open Code Review.

…s memories

delete_node_by_prams deleted the graph nodes but never purged the matching
vectors from vec_db, so a deleted memory stayed retrievable through vector
search while the delete API answered success. The sibling paths clear() and
delete_node_by_mem_cube_id() already purge vec_db; this one omitted the step.

The node ids are collected and the nodes deleted in a single statement, so the
ids that are purged are exactly the nodes this call removed - two separate
auto-commit statements leave a window in which a concurrent writer adds nodes
matching the same filter, whose vectors would then be orphaned. Also drops the
debug print() calls from this method (the repo logs via get_logger).
@simpleqt
simpleqt force-pushed the fix/delete-purges-vectors branch from dad234f to fa103c0 Compare October 22, 2026 08:18
@simpleqt

Copy link
Copy Markdown
Author

Thanks for the review. I have reworked the change; all five points are addressed in fa103c0c.

#1 and #3 (TOCTOU between the collect query and the delete query) - fixed, and thank you, this was a real defect in my first version. I had collected the ids and then deleted in two separate auto-commit statements. Both paths now do it in one statement, so the ids that get purged from vec_db are exactly the nodes the call removed:

MATCH (n:Memory) WHERE {where} WITH n DETACH DELETE n RETURN n.id AS id

I used RETURN n.id AS id rather than RETURN id (from WITH n, n.id AS id): after DETACH DELETE n the node's properties are gone, and I would rather not depend on how Neo4j evaluates a property read on a deleted entity. n.id in the RETURN is evaluated before the delete. The base class still takes its count from the same summary .consume() produced, so deleted_count is unchanged there; the community class counts the returned ids.

delete_query = (
    f"MATCH (n:Memory) WHERE {ids_where} WITH n DETACH DELETE n RETURN n.id AS id"
)
logger.info(f"[delete_node_by_prams] delete_query: {delete_query}")

with self.driver.session(database=self.db_name) as session:
    deleted_ids = [record["id"] for record in session.run(delete_query, **params)]
    deleted_count = len(deleted_ids)

It is also one round trip instead of two. tests/graph_dbs/test_delete_vec_cleanup.py now has test_collect_and_delete_are_one_statement, which asserts exactly one DETACH DELETE statement is issued and that it returns the ids. I checked that this test is not vacuous: run against the previous two-statement revision it fails (assert 0 == 1 on the delete-statement count), and it passes with the fix.

#2 and #5 (debug print()) - removed from this method. One correction for the record: these were not introduced by this PR. All three are on main today - count_query, delete_query and params - and git blame attributes the latter two to a much older commit (#615). My first version only changed the count_query print into an id_collect_query print; the other two lines were untouched context. They are still wrong (your point stands, and AGENTS.md says the same: log via memos.log.get_logger, do not print), and since this commit rewrites that block anyway I removed all four prints from delete_node_by_prams. I left the other print() calls elsewhere in graph_dbs/ alone to keep this PR scoped to the reported bug - happy to sweep those in a separate change if you want them gone.

#4 (result of session.run(delete_query) never consumed in the community class). Addressed by the same rework: the ids are now read by iterating the result, which drains it, so the deletion is complete before the session closes and a server-side error surfaces rather than being swallowed. That branch no longer exists. I did not add an extra .consume() to the base class, where one is already called.

Verification on the new revision

  • pytest tests/graph_dbs -q -> 40 passed, 3 skipped
  • ruff check and ruff format --check clean, both on the changed files and repo-wide (631 files already formatted); ruff 0.11.8, matching the ^0.11.8 pin in pyproject.toml. This caught two lines of my own that exceeded the formatter width, now fixed.

The behaviour under test is unchanged: deleting by memory_ids, file_ids or filter still purges the matching vectors, an empty match set still issues no vector delete, and the neo4j (enterprise) backend with no vec_db still degrades to a no-op.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:database graph_db + vector_db | 图数据库与向量数据库 status:in-progress Someone or AI is working on it | 人工或 AI 正在处理

Projects

None yet

Development

Successfully merging this pull request may close these issues.

删除记忆接口返回成功但记忆未实际删除

4 participants