Skip to content

Commit 253e240

Browse files
phernandezclaude
andcommitted
fix(core): use (type, id) keys in vector search hydration to prevent id collisions
Root cause: entity, observation, and relation rows in search_index carry ids from independent auto-increment sequences, so rows of different types routinely share the same numeric id (guaranteed in young databases). _search_vector_only parsed each vector hit's chunk_key (e.g. 'entity:4:0') but discarded the type, and _fetch_search_index_rows_by_ids keyed its result dict by bare row.id with no type discrimination. Whichever row the database returned last clobbered the other in the dict; the clobbered hit then hydrated against the wrong row or found None and was silently dropped from results. The FTS-filter branch already guarded this with (id, type) tuples, but the primary vector lookup path and the hybrid fusion maps missed the same treatment. Fix: introduce a SearchIndexKey = tuple[str, int] alias and key every map in the vector/hybrid retrieval path by (type, id) — the similarity and chunk maps in _search_vector_only, the _fetch_search_index_rows_by_ids result, the FTS-filter allowed keys, and the rows/fts/vec/fused score maps in _search_hybrid. The SQL stays unchanged; bare ids are deduped before the IN query and rows are discriminated by row.type when building dict keys. Tests: end-to-end SQLite regression test indexes an entity row and a relation row sharing id 7, syncs vectors for both, and asserts vector search returns both rows (and that the entity survives a search_item_types filter); a hybrid fusion unit test asserts an entity and relation sharing id 1 stay distinct with single-source scores. Both fail without the fix. Existing mocked vector tests updated for tuple keys. Fixes #982 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: phernandez <paul@basicmachines.co>
1 parent b3bdd59 commit 253e240

5 files changed

Lines changed: 181 additions & 51 deletions

File tree

src/basic_memory/repository/search_repository_base.py

Lines changed: 53 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@
4040
OVERSIZED_ENTITY_VECTOR_SHARD_SIZE = 256
4141
_SQLITE_MAX_PREPARE_WINDOW = 8
4242

43+
# Entity, observation, and relation rows in search_index carry ids from independent
44+
# auto-increment sequences, so a bare id is ambiguous across row types. Every map in
45+
# the vector/hybrid retrieval path must key rows by (type, id) to avoid collisions.
46+
type SearchIndexKey = tuple[str, int]
47+
4348

4449
@dataclass
4550
class VectorSyncBatchResult:
@@ -1857,7 +1862,7 @@ async def _dispatch_retrieval_mode(
18571862
# ------------------------------------------------------------------
18581863

18591864
@staticmethod
1860-
def _parse_chunk_key(chunk_key: str) -> tuple[str, int]:
1865+
def _parse_chunk_key(chunk_key: str) -> SearchIndexKey:
18611866
"""Parse a chunk_key like 'observation:5:0' into (type, search_index_id)."""
18621867
parts = chunk_key.split(":")
18631868
return parts[0], int(parts[1])
@@ -1932,26 +1937,27 @@ def _log_vector_summary() -> None:
19321937

19331938
hydrate_start = time.perf_counter()
19341939
# Build per-search_index_row similarity scores from chunk-level results.
1935-
# Each chunk_key encodes the search_index row type and id.
1940+
# Each chunk_key encodes the search_index row type and id; keep both as the
1941+
# key because different row types can share the same numeric id (#982).
19361942
# Track the best similarity per row (for ranking) and all chunks (for context).
1937-
similarity_by_si_id: dict[int, float] = {}
1938-
chunks_by_si_id: dict[int, list[tuple[float, str]]] = {}
1943+
similarity_by_si_key: dict[SearchIndexKey, float] = {}
1944+
chunks_by_si_key: dict[SearchIndexKey, list[tuple[float, str]]] = {}
19391945
for row in vector_rows:
19401946
chunk_key = row.get("chunk_key", "")
19411947
distance = float(row["best_distance"])
19421948
similarity = self._distance_to_similarity(distance)
19431949
chunk_text = row.get("chunk_text", "")
19441950
try:
1945-
_, si_id = self._parse_chunk_key(chunk_key)
1951+
si_key = self._parse_chunk_key(chunk_key)
19461952
except (ValueError, IndexError):
19471953
# Fallback: group by entity_id for chunks without parseable keys
19481954
continue
1949-
current = similarity_by_si_id.get(si_id)
1955+
current = similarity_by_si_key.get(si_key)
19501956
if current is None or similarity > current:
1951-
similarity_by_si_id[si_id] = similarity
1952-
chunks_by_si_id.setdefault(si_id, []).append((similarity, chunk_text))
1957+
similarity_by_si_key[si_key] = similarity
1958+
chunks_by_si_key.setdefault(si_key, []).append((similarity, chunk_text))
19531959

1954-
if not similarity_by_si_id:
1960+
if not similarity_by_si_key:
19551961
hydrate_ms = (time.perf_counter() - hydrate_start) * 1000
19561962
_log_vector_summary()
19571963
return []
@@ -1962,16 +1968,17 @@ def _log_vector_summary() -> None:
19621968
min_similarity if min_similarity is not None else self._semantic_min_similarity
19631969
)
19641970
if effective_min_similarity > 0.0:
1965-
similarity_by_si_id = {
1966-
k: v for k, v in similarity_by_si_id.items() if v >= effective_min_similarity
1971+
similarity_by_si_key = {
1972+
k: v for k, v in similarity_by_si_key.items() if v >= effective_min_similarity
19671973
}
1968-
if not similarity_by_si_id:
1974+
if not similarity_by_si_key:
19691975
hydrate_ms = (time.perf_counter() - hydrate_start) * 1000
19701976
_log_vector_summary()
19711977
return []
19721978

1973-
# Fetch the actual search_index rows
1974-
si_ids = list(similarity_by_si_id.keys())
1979+
# Fetch the actual search_index rows. Colliding (type, id) keys share one
1980+
# bare id, so deduplicate while preserving first-seen order.
1981+
si_ids = list(dict.fromkeys(si_id for _, si_id in similarity_by_si_key))
19751982
search_index_rows = await self._fetch_search_index_rows_by_ids(si_ids)
19761983

19771984
# Apply optional filters if requested
@@ -2003,16 +2010,14 @@ def _log_vector_summary() -> None:
20032010
limit=VECTOR_FILTER_SCAN_LIMIT,
20042011
offset=0,
20052012
)
2006-
# Use (id, type) tuples to avoid collisions between different
2013+
# Use (type, id) tuples to avoid collisions between different
20072014
# search_index row types that share the same auto-increment id.
2008-
allowed_keys = {(row.id, row.type) for row in filtered_rows if row.id is not None}
2009-
search_index_rows = {
2010-
k: v for k, v in search_index_rows.items() if (v.id, v.type) in allowed_keys
2011-
}
2015+
allowed_keys = {(row.type, row.id) for row in filtered_rows if row.id is not None}
2016+
search_index_rows = {k: v for k, v in search_index_rows.items() if k in allowed_keys}
20122017

20132018
ranked_rows: list[SearchIndexRow] = []
2014-
for si_id, similarity in similarity_by_si_id.items():
2015-
row = search_index_rows.get(si_id)
2019+
for si_key, similarity in similarity_by_si_key.items():
2020+
row = search_index_rows.get(si_key)
20162021
if row is None:
20172022
continue
20182023

@@ -2022,7 +2027,7 @@ def _log_vector_summary() -> None:
20222027
if content_snippet and len(content_snippet) <= SMALL_NOTE_CONTENT_LIMIT:
20232028
matched_chunk_text = content_snippet
20242029
else:
2025-
si_chunks = chunks_by_si_id.get(si_id, [])
2030+
si_chunks = chunks_by_si_key.get(si_key, [])
20262031
si_chunks.sort(key=lambda c: c[0], reverse=True)
20272032
top_texts = [text for _, text in si_chunks[:TOP_CHUNKS_PER_RESULT]]
20282033
matched_chunk_text = "\n---\n".join(top_texts) if top_texts else None
@@ -2088,8 +2093,12 @@ async def _fetch_entity_rows_by_ids(self, entity_ids: list[int]) -> dict[int, Se
20882093

20892094
async def _fetch_search_index_rows_by_ids(
20902095
self, row_ids: list[int]
2091-
) -> dict[int, SearchIndexRow]:
2092-
"""Fetch search_index rows by their primary key (id), any type."""
2096+
) -> dict[SearchIndexKey, SearchIndexRow]:
2097+
"""Fetch search_index rows by id, keyed by (type, id) to disambiguate types.
2098+
2099+
A bare id can match one row per type (independent id sequences), so the
2100+
result must carry every matching row rather than letting one clobber another.
2101+
"""
20932102
if not row_ids:
20942103
return {}
20952104
placeholders = ",".join(f":id_{idx}" for idx in range(len(row_ids)))
@@ -2106,11 +2115,11 @@ async def _fetch_search_index_rows_by_ids(
21062115
WHERE project_id = :project_id
21072116
AND id IN ({placeholders})
21082117
"""
2109-
result: dict[int, SearchIndexRow] = {}
2118+
result: dict[SearchIndexKey, SearchIndexRow] = {}
21102119
async with db.scoped_session(self.session_maker) as session:
21112120
row_result = await session.execute(text(sql), params)
21122121
for row in row_result.fetchall():
2113-
result[row.id] = SearchIndexRow(
2122+
result[(row.type, row.id)] = SearchIndexRow(
21142123
project_id=self.project_id,
21152124
id=row.id,
21162125
title=row.title,
@@ -2156,7 +2165,7 @@ async def _search_hybrid(
21562165
) -> List[SearchIndexRow]:
21572166
"""Fuse FTS and vector results using score-based fusion.
21582167
2159-
Uses search_index row id as the fusion key. The formula
2168+
Uses the search_index (type, id) pair as the fusion key. The formula
21602169
``max(vec, fts) + FUSION_BONUS * min(vec, fts)`` preserves
21612170
the dominant signal and rewards dual-source agreement.
21622171
"""
@@ -2199,50 +2208,52 @@ async def _search_hybrid(
21992208
vector_ms = (time.perf_counter() - vector_start) * 1000
22002209
fusion_start = time.perf_counter()
22012210

2202-
# --- Score-based fusion keyed on search_index row id ---
2211+
# --- Score-based fusion keyed on (type, id) ---
2212+
# A bare row id collides across row types (independent id sequences), so
2213+
# fusion must key on (type, id) or distinct rows would merge (#982).
22032214
# FTS scores are normalized to [0, 1] (BM25 is unbounded).
22042215
# Vector scores are used raw — already calibrated [0, 1] by _distance_to_similarity().
2205-
rows_by_id: dict[int, SearchIndexRow] = {}
2216+
rows_by_key: dict[SearchIndexKey, SearchIndexRow] = {}
22062217

22072218
# Normalize FTS scores to [0, 1] — handles both SQLite (negative bm25)
22082219
# and Postgres (positive ts_rank) by using absolute values
22092220
fts_abs = [abs(row.score or 0.0) for row in fts_results]
22102221
fts_max = max(fts_abs) if fts_abs else 1.0
22112222

2212-
fts_scores: dict[int, float] = {}
2223+
fts_scores: dict[SearchIndexKey, float] = {}
22132224
for row in fts_results:
22142225
if row.id is None:
22152226
continue
22162227
norm = abs(row.score or 0.0) / fts_max if fts_max > 0 else 0.0
22172228
# Gate: FTS scores below threshold contribute zero
22182229
if norm < FTS_GATE_THRESHOLD:
22192230
norm = 0.0
2220-
fts_scores[row.id] = norm
2221-
rows_by_id[row.id] = row
2231+
fts_scores[(row.type, row.id)] = norm
2232+
rows_by_key[(row.type, row.id)] = row
22222233

2223-
vec_scores: dict[int, float] = {}
2234+
vec_scores: dict[SearchIndexKey, float] = {}
22242235
for row in vector_results:
22252236
if row.id is None:
22262237
continue
22272238
# Trigger: no re-normalization by vec_max
22282239
# Why: vector similarity is already calibrated [0, 1]; re-normalizing
22292240
# inflates weak matches when the entire result set is mediocre
2230-
vec_scores[row.id] = row.score or 0.0
2231-
rows_by_id[row.id] = row
2241+
vec_scores[(row.type, row.id)] = row.score or 0.0
2242+
rows_by_key[(row.type, row.id)] = row
22322243

22332244
# Fuse: max(v, f) + FUSION_BONUS * min(v, f)
22342245
# Preserves the dominant signal; bonus rewards dual-source agreement.
22352246
# Output range: [0, 1.3] for dual-source, [0, 1.0] for single-source.
2236-
fused_scores: dict[int, float] = {}
2237-
for row_id in fts_scores.keys() | vec_scores.keys():
2238-
v = vec_scores.get(row_id, 0.0)
2239-
f = fts_scores.get(row_id, 0.0)
2240-
fused_scores[row_id] = max(v, f) + FUSION_BONUS * min(v, f)
2247+
fused_scores: dict[SearchIndexKey, float] = {}
2248+
for row_key in fts_scores.keys() | vec_scores.keys():
2249+
v = vec_scores.get(row_key, 0.0)
2250+
f = fts_scores.get(row_key, 0.0)
2251+
fused_scores[row_key] = max(v, f) + FUSION_BONUS * min(v, f)
22412252

22422253
ranked = sorted(fused_scores.items(), key=lambda item: item[1], reverse=True)
22432254
output: list[SearchIndexRow] = []
2244-
for row_id, fused_score in ranked[offset : offset + limit]:
2245-
row = rows_by_id[row_id]
2255+
for row_key, fused_score in ranked[offset : offset + limit]:
2256+
row = rows_by_key[row_key]
22462257
# Trigger: FTS-only results have no matched_chunk_text from vector search.
22472258
# Why: without chunk text, API falls back to truncated content, losing answer text.
22482259
# Outcome: FTS-only results get full content_snippet as matched_chunk.

tests/repository/test_hybrid_fusion.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,34 @@ async def test_zero_score_produces_zero_fused():
228228
assert results[0].score == pytest.approx(0.0, rel=1e-6)
229229

230230

231+
@pytest.mark.asyncio
232+
async def test_cross_type_id_collision_keeps_both_results():
233+
"""An entity and a relation sharing the same numeric id stay distinct (#982).
234+
235+
search_index row types have independent id sequences, so fusing on a bare
236+
row id merged unrelated rows into one result and dropped the other.
237+
"""
238+
repo = ConcreteSearchRepo()
239+
240+
fts_results = [FakeRow(id=1, type="entity", score=5.0, title="entity-row")]
241+
vector_results = [FakeRow(id=1, type="relation", score=0.8, title="relation-row")]
242+
243+
with (
244+
patch.object(repo, "search", new_callable=AsyncMock, return_value=fts_results),
245+
patch.object(
246+
repo, "_search_vector_only", new_callable=AsyncMock, return_value=vector_results
247+
),
248+
):
249+
results = await repo._search_hybrid(**HYBRID_KWARGS)
250+
251+
assert {(r.type, r.id) for r in results} == {("entity", 1), ("relation", 1)}
252+
# Single-source scores must not earn the dual-source fusion bonus across types.
253+
entity_result = next(r for r in results if r.type == "entity")
254+
relation_result = next(r for r in results if r.type == "relation")
255+
assert entity_result.score == pytest.approx(1.0, rel=1e-6)
256+
assert relation_result.score == pytest.approx(0.8, rel=1e-6)
257+
258+
231259
@pytest.mark.asyncio
232260
async def test_fts_only_result_gets_matched_chunk_from_content_snippet():
233261
"""FTS-only results should have matched_chunk_text populated from content_snippet."""

tests/repository/test_sqlite_vector_search_repository.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,32 @@ def _entity_row(
7676
)
7777

7878

79+
def _relation_row(
80+
*,
81+
project_id: int,
82+
row_id: int,
83+
entity_id: int,
84+
title: str,
85+
permalink: str,
86+
relation_type: str,
87+
) -> SearchIndexRow:
88+
now = datetime.now(timezone.utc)
89+
return SearchIndexRow(
90+
project_id=project_id,
91+
id=row_id,
92+
type=SearchItemType.RELATION.value,
93+
title=title,
94+
permalink=permalink,
95+
file_path=f"{permalink}.md",
96+
metadata=None,
97+
entity_id=entity_id,
98+
from_id=entity_id,
99+
relation_type=relation_type,
100+
created_at=now,
101+
updated_at=now,
102+
)
103+
104+
79105
def _enable_semantic(
80106
search_repository: SQLiteSearchRepository,
81107
embedding_provider: StubEmbeddingProvider | None = None,
@@ -498,6 +524,71 @@ async def test_sqlite_vector_search_returns_ranked_entities(search_repository):
498524
assert all(result.type == SearchItemType.ENTITY.value for result in results)
499525

500526

527+
@pytest.mark.asyncio
528+
async def test_sqlite_vector_search_survives_cross_type_id_collision(search_repository):
529+
"""Entity and relation rows sharing one numeric id must both hydrate (#982).
530+
531+
Entity, observation, and relation rows carry ids from independent
532+
auto-increment sequences, so search_index rows of different types routinely
533+
share the same numeric id. Keying vector hydration by bare id collapsed
534+
colliding hits into one dict slot and silently dropped the other result.
535+
"""
536+
if not isinstance(search_repository, SQLiteSearchRepository):
537+
pytest.skip("sqlite-vec repository behavior is local SQLite-only.")
538+
539+
_enable_semantic(search_repository)
540+
await search_repository.init_search_index()
541+
await search_repository.bulk_index_items(
542+
[
543+
_entity_row(
544+
project_id=search_repository.project_id,
545+
row_id=7,
546+
entity_id=701,
547+
title="Auth Token Design",
548+
permalink="specs/auth-token-design",
549+
content_stems="auth token session login design",
550+
),
551+
# Same numeric id as the entity row above, different row type.
552+
_relation_row(
553+
project_id=search_repository.project_id,
554+
row_id=7,
555+
entity_id=702,
556+
title="login flow relates to auth token design",
557+
permalink="specs/login-flow/relates-to/auth-token-design",
558+
relation_type="relates_to",
559+
),
560+
]
561+
)
562+
await search_repository.sync_entity_vectors(701)
563+
await search_repository.sync_entity_vectors(702)
564+
565+
results = await search_repository.search(
566+
search_text="session token auth",
567+
retrieval_mode=SearchRetrievalMode.VECTOR,
568+
limit=5,
569+
offset=0,
570+
)
571+
572+
# Both rows match the query; both share id=7 and must survive hydration.
573+
assert len(results) == 2
574+
assert {result.type for result in results} == {
575+
SearchItemType.ENTITY.value,
576+
SearchItemType.RELATION.value,
577+
}
578+
entity_result = next(r for r in results if r.type == SearchItemType.ENTITY.value)
579+
assert entity_result.permalink == "specs/auth-token-design"
580+
581+
# The type filter must keep the entity even though a relation shares its id.
582+
filtered = await search_repository.search(
583+
search_text="session token auth",
584+
search_item_types=[SearchItemType.ENTITY],
585+
retrieval_mode=SearchRetrievalMode.VECTOR,
586+
limit=5,
587+
offset=0,
588+
)
589+
assert [r.permalink for r in filtered] == ["specs/auth-token-design"]
590+
591+
501592
@pytest.mark.asyncio
502593
async def test_sqlite_hybrid_search_combines_fts_and_vector(search_repository):
503594
"""Hybrid mode fuses FTS and vector results with score-based fusion."""

tests/repository/test_vector_pagination.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ async def test_page1_scores_gte_page2_scores():
133133

134134
repo._embedding_provider = _EmbeddingProvider()
135135

136-
fake_index_rows = {i: FakeRow(id=i) for i in range(20)}
136+
fake_index_rows = {("entity", i): FakeRow(id=i) for i in range(20)}
137137

138138
async def run_page(offset, limit):
139139
with (

0 commit comments

Comments
 (0)