Skip to content

Commit eaab505

Browse files
groksrcclaude
andcommitted
fix(search): keep colliding observation permalinks searchable and clean up index rows by entity id
Observation permalinks truncate content to 200 chars, so two distinct observations sharing a category and 200-char prefix produced identical synthetic permalinks and the second row was silently dropped from the search index, making it unfindable. - index_entity_markdown now disambiguates colliding observation permalinks by appending "-{obs.id}" (in a loop, so a suffixed permalink that itself collides is re-suffixed until unique) - SearchService.handle_delete and SyncService.handle_delete now clean up by entity_id instead of recomputing synthetic permalinks, which would miss disambiguated rows and leave ghost search results; incoming-relation rows (indexed under the source entity) are still removed by permalink - regression tests at unit (SearchService), sync (SyncService), and MCP integration levels, verified failing against the old code on both SQLite and Postgres Fixes #909 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent de53e0e commit eaab505

5 files changed

Lines changed: 446 additions & 40 deletions

File tree

src/basic_memory/services/search_service.py

Lines changed: 23 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -884,9 +884,21 @@ async def index_entity_markdown(
884884
seen_permalinks: set[str] = {entity.permalink} if entity.permalink else set()
885885
for obs in entity.observations:
886886
obs_permalink = obs.permalink
887-
if obs_permalink in seen_permalinks:
888-
logger.debug(f"Skipping duplicate observation permalink: {obs_permalink}")
889-
continue
887+
# Trigger: observation permalinks truncate content to 200 chars (Postgres
888+
# btree index limit), so distinct observations sharing a category and a
889+
# 200-char content prefix produce identical synthetic permalinks.
890+
# Why: permalinks must stay unique within the batch — the Postgres bulk
891+
# upsert conflicts on (permalink, project_id) — but dropping the row made
892+
# the second observation unsearchable (#909).
893+
# Outcome: append the observation's unique DB id so every observation is
894+
# indexed and remains findable. A while (not if) re-checks the suffixed
895+
# permalink too: content ending in "-{id}" can make it collide with
896+
# another observation's natural permalink in the same batch. Each pass
897+
# appends "-{obs.id}", strictly lengthening the permalink against a
898+
# finite seen set, so the loop terminates.
899+
while obs_permalink in seen_permalinks:
900+
logger.debug(f"Disambiguating duplicate observation permalink: {obs_permalink}")
901+
obs_permalink = f"{obs_permalink}-{obs.id}"
890902
seen_permalinks.add(obs_permalink)
891903

892904
obs_content_stems = _strip_nul(
@@ -965,23 +977,14 @@ async def handle_delete(self, entity: Entity):
965977
f"observations={len(entity.observations)}, relations={len(entity.outgoing_relations)}"
966978
)
967979

968-
# Clean up search index - same logic as sync_service.handle_delete()
969-
permalinks = (
970-
[entity.permalink]
971-
+ [o.permalink for o in entity.observations]
972-
+ [r.permalink for r in entity.outgoing_relations]
973-
)
974-
975-
logger.debug(
976-
f"Deleting search index entries for entity_id={entity.id}, "
977-
f"index_entries={len(permalinks)}"
978-
)
979-
980-
for permalink in permalinks:
981-
if permalink:
982-
await self.delete_by_permalink(permalink)
983-
else:
984-
await self.delete_by_entity_id(entity.id)
980+
# Trigger: every row indexed for this entity (entity, observations, outgoing
981+
# relations) carries entity_id, and colliding observation permalinks are
982+
# disambiguated at index time (#909 appends "-{obs.id}") so recomputing the
983+
# synthetic permalink here would no longer match those rows.
984+
# Why: search_index has no FK cascade from entity, so permalink-based cleanup
985+
# would leave disambiguated rows behind as ghost search results.
986+
# Outcome: delete by entity_id, removing every row regardless of permalink shape.
987+
await self.delete_by_entity_id(entity.id)
985988

986989
# Trigger: entity deletion removes the source rows for this note.
987990
# Why: semantic chunks/embeddings are stored separately from search_index rows.

src/basic_memory/sync/sync_service.py

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1291,23 +1291,26 @@ async def handle_delete(self, file_path: str):
12911291
# Delete from db (this cascades to observations/relations)
12921292
await self.entity_service.delete_entity_by_file_path(file_path)
12931293

1294-
# Clean up search index
1295-
permalinks = (
1296-
[entity.permalink]
1297-
+ [o.permalink for o in entity.observations]
1298-
+ [r.permalink for r in entity.relations]
1299-
)
1300-
13011294
logger.debug(
1302-
f"Cleaning up search index for entity_id={entity.id}, file_path={file_path}, "
1303-
f"index_entries={len(permalinks)}"
1295+
f"Cleaning up search index for entity_id={entity.id}, file_path={file_path}"
13041296
)
13051297

1306-
for permalink in permalinks:
1307-
if permalink:
1308-
await self.search_service.delete_by_permalink(permalink)
1309-
else:
1310-
await self.search_service.delete_by_entity_id(entity.id)
1298+
# Trigger: every row indexed for this entity (entity, observations, outgoing
1299+
# relations) carries entity_id, and colliding observation permalinks are
1300+
# disambiguated at index time (#909 appends "-{obs.id}") so recomputing the
1301+
# synthetic permalink here would no longer match those rows.
1302+
# Why: search_index has no FK cascade from entity, so permalink-based cleanup
1303+
# would leave disambiguated rows behind as ghost search results.
1304+
# Outcome: delete by entity_id, removing every row regardless of permalink shape.
1305+
await self.search_service.delete_by_entity_id(entity.id)
1306+
1307+
# Trigger: incoming relations are indexed under the *source* entity's
1308+
# entity_id, so the entity-id cleanup above cannot reach their rows.
1309+
# Why: those rows point at this now-deleted entity; the source entity
1310+
# re-indexes them as forward references on its next sync.
1311+
# Outcome: remove them by permalink, preserving prior cleanup behavior.
1312+
for relation in entity.incoming_relations:
1313+
await self.search_service.delete_by_permalink(relation.permalink)
13111314

13121315
async def handle_move(self, old_path, new_path):
13131316
logger.debug("Moving entity", old_path=old_path, new_path=new_path)
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
"""Regression test for https://github.com/basicmachines-co/basic-memory/issues/909.
2+
3+
Distinct observations that share a category and the first 200 chars of content
4+
collide on their synthetic permalink (content is truncated to 200 chars for the
5+
Postgres btree index limit). The second observation was silently dropped from
6+
the search index, making it unfindable.
7+
"""
8+
9+
import json
10+
from typing import Any
11+
12+
import pytest
13+
from fastmcp import Client
14+
15+
16+
def _json_content(tool_result) -> Any:
17+
"""Parse a FastMCP tool result content block into JSON."""
18+
assert len(tool_result.content) == 1
19+
assert tool_result.content[0].type == "text"
20+
return json.loads(tool_result.content[0].text) # pyright: ignore [reportAttributeAccessIssue]
21+
22+
23+
@pytest.mark.asyncio
24+
async def test_duplicate_category_content_observations_both_searchable(
25+
mcp_server, app, test_project
26+
):
27+
"""Both observations must be indexed even when their synthetic permalinks collide."""
28+
async with Client(mcp_server) as client:
29+
prefix = "x" * 210 # > 200 so truncation makes the permalinks identical
30+
content = (
31+
"# Dup Obs Note\n\n"
32+
"## Observations\n"
33+
f"- [note] {prefix} ALPHA_UNIQUE_MARKER\n"
34+
f"- [note] {prefix} BETA_UNIQUE_MARKER\n"
35+
)
36+
await client.call_tool(
37+
"write_note",
38+
{
39+
"project": test_project.name,
40+
"title": "Dup Obs Note",
41+
"directory": "dup",
42+
"content": content,
43+
},
44+
)
45+
46+
# Both observations should be independently findable by their unique suffix
47+
for marker in ("ALPHA_UNIQUE_MARKER", "BETA_UNIQUE_MARKER"):
48+
result = await client.call_tool(
49+
"search_notes",
50+
{
51+
"project": test_project.name,
52+
"query": marker,
53+
"search_type": "text",
54+
"entity_types": ["observation"],
55+
"output_format": "json",
56+
},
57+
)
58+
data = _json_content(result)
59+
snippets = [r.get("content") or "" for r in data["results"]]
60+
assert any(marker in s for s in snippets), (
61+
f"observation containing {marker} was dropped from the search index "
62+
"due to a synthetic-permalink collision (content truncated to 200 chars). "
63+
"results=" + json.dumps(data, default=str)[:800]
64+
)
65+
66+
67+
@pytest.mark.asyncio
68+
async def test_delete_note_with_colliding_observations_leaves_no_ghost_rows(
69+
mcp_server, app, test_project, search_service
70+
):
71+
"""Regression test for #909 follow-up: deletion must clean disambiguated rows.
72+
73+
Colliding observation permalinks are disambiguated at index time by appending
74+
"-{obs.id}". Deleting the note must remove those rows too; otherwise the
75+
disambiguated observation survives in the search index as a ghost row
76+
pointing at the deleted file.
77+
78+
The post-delete assertions inspect the index through ``search_service``
79+
rather than the ``search_notes`` tool: the MCP search pipeline happens to
80+
hide rows whose entity is gone, which would mask the orphan.
81+
"""
82+
from basic_memory.schemas.search import SearchQuery
83+
84+
async with Client(mcp_server) as client:
85+
prefix = "y" * 210 # > 200 so truncation makes the permalinks identical
86+
content = (
87+
"# Ghost Obs Note\n\n"
88+
"## Observations\n"
89+
f"- [note] {prefix} GHOST_ALPHA_MARKER\n"
90+
f"- [note] {prefix} GHOST_BETA_MARKER\n"
91+
)
92+
await client.call_tool(
93+
"write_note",
94+
{
95+
"project": test_project.name,
96+
"title": "Ghost Obs Note",
97+
"directory": "ghost",
98+
"content": content,
99+
},
100+
)
101+
102+
# Both observations are searchable before deletion
103+
for marker in ("GHOST_ALPHA_MARKER", "GHOST_BETA_MARKER"):
104+
result = await client.call_tool(
105+
"search_notes",
106+
{
107+
"project": test_project.name,
108+
"query": marker,
109+
"search_type": "text",
110+
"entity_types": ["observation"],
111+
"output_format": "json",
112+
},
113+
)
114+
data = _json_content(result)
115+
assert any(marker in (r.get("content") or "") for r in data["results"])
116+
117+
# Both rows exist in the search index itself, one under a "-{obs.id}" suffix
118+
index_rows = await search_service.search(SearchQuery(text="GHOST_BETA_MARKER"))
119+
assert any(r.type == "observation" for r in index_rows)
120+
121+
delete_result = await client.call_tool(
122+
"delete_note",
123+
{
124+
"project": test_project.name,
125+
"identifier": "Ghost Obs Note",
126+
},
127+
)
128+
assert "true" in delete_result.content[0].text.lower() # pyright: ignore [reportAttributeAccessIssue]
129+
130+
# No ghost rows remain in the index - including the disambiguated row
131+
for marker in ("GHOST_ALPHA_MARKER", "GHOST_BETA_MARKER"):
132+
index_rows = await search_service.search(SearchQuery(text=marker))
133+
assert index_rows == [], (
134+
f"search index row containing {marker} survived note deletion as a ghost: "
135+
f"{[(r.type, r.permalink) for r in index_rows]}"
136+
)

0 commit comments

Comments
 (0)