4040OVERSIZED_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
4550class 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.
0 commit comments