Push document-id filter into chunk search query
This commit is contained in:
parent
31fc22ce9d
commit
821b7361e9
4 changed files with 588 additions and 21 deletions
|
|
@ -11,6 +11,7 @@
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **Chat TUI now renders citations again.** After the 0.42.1 flattening of skill state `citations` to `list[str]`, the TUI still indexed `citations[-1]` and iterated the resulting chunk-id string character-by-character, so no citations resolved through `citation_index` and the citation panel stayed empty. Fixed by iterating `state.citations` directly.
|
- **Chat TUI now renders citations again.** After the 0.42.1 flattening of skill state `citations` to `list[str]`, the TUI still indexed `citations[-1]` and iterated the resulting chunk-id string character-by-character, so no citations resolved through `citation_index` and the citation panel stayed empty. Fixed by iterating `state.citations` directly.
|
||||||
|
- **`search(..., filter=...)` no longer silently under-returns.** The filter path used to materialize LanceDB's top-N window, filter to matching `document_id`s in pandas, and `head(limit)`. When matching chunks lived outside that top-N window (selective filters, broad queries), the caller got fewer than `limit` results even though plenty of matching chunks existed in the index. The document filter is now pushed down into the chunk query as `document_id IN (...)` so `.limit(limit)` applies to matching chunks directly. Behavior change: searches that previously under-returned will start returning the requested count.
|
||||||
|
|
||||||
## [0.42.1] - 2026-04-22
|
## [0.42.1] - 2026-04-22
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -236,24 +236,25 @@ class ChunkRepository:
|
||||||
"""
|
"""
|
||||||
if not query.strip():
|
if not query.strip():
|
||||||
return []
|
return []
|
||||||
filtered_doc_ids = None
|
|
||||||
|
chunk_filter: str | None = None
|
||||||
if filter:
|
if filter:
|
||||||
# We perform filtering as a two-step process, first filtering documents, then
|
# Translate the document-level filter into a chunk-level
|
||||||
# filtering chunks based on those document IDs.
|
# document_id IN (...) clause so LanceDB can combine it with
|
||||||
# This is because LanceDB does not support joins directly in search queries.
|
# limit. The previous two-step pattern (materialize top-N,
|
||||||
|
# filter in pandas, head(limit)) silently under-returned
|
||||||
|
# whenever the top-N window lacked `limit` matching chunks.
|
||||||
docs_df = await (
|
docs_df = await (
|
||||||
self.store.documents_table.query()
|
self.store.documents_table.query()
|
||||||
.select(["id"])
|
.select(["id"])
|
||||||
.where(filter)
|
.where(filter)
|
||||||
.to_pandas()
|
.to_pandas()
|
||||||
)
|
)
|
||||||
# Early exit if no documents match the filter
|
|
||||||
if docs_df.empty:
|
if docs_df.empty:
|
||||||
return []
|
return []
|
||||||
# Keep as pandas Series for efficient vectorized operations
|
id_list = ", ".join(f"'{d}'" for d in docs_df["id"])
|
||||||
filtered_doc_ids = docs_df["id"]
|
chunk_filter = f"document_id IN ({id_list})"
|
||||||
|
|
||||||
# Prepare search query based on search type
|
|
||||||
if search_type == "vector":
|
if search_type == "vector":
|
||||||
query_embedding = await self.embedder.embed_query(query)
|
query_embedding = await self.embedder.embed_query(query)
|
||||||
results = (
|
results = (
|
||||||
|
|
@ -262,17 +263,13 @@ class ChunkRepository:
|
||||||
.column("vector")
|
.column("vector")
|
||||||
.refine_factor(self.store._config.search.vector_refine_factor)
|
.refine_factor(self.store._config.search.vector_refine_factor)
|
||||||
)
|
)
|
||||||
|
|
||||||
elif search_type == "fts":
|
elif search_type == "fts":
|
||||||
results = self.store.chunks_table.query().nearest_to_text(
|
results = self.store.chunks_table.query().nearest_to_text(
|
||||||
query, columns="content_fts"
|
query, columns="content_fts"
|
||||||
)
|
)
|
||||||
|
|
||||||
else: # hybrid (default)
|
else: # hybrid (default)
|
||||||
query_embedding = await self.embedder.embed_query(query)
|
query_embedding = await self.embedder.embed_query(query)
|
||||||
# Create RRF reranker
|
|
||||||
reranker = RRFReranker()
|
reranker = RRFReranker()
|
||||||
# Perform native hybrid search with RRF reranking
|
|
||||||
results = (
|
results = (
|
||||||
self.store.chunks_table.query()
|
self.store.chunks_table.query()
|
||||||
.nearest_to(query_embedding)
|
.nearest_to(query_embedding)
|
||||||
|
|
@ -282,15 +279,8 @@ class ChunkRepository:
|
||||||
.rerank(reranker)
|
.rerank(reranker)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Apply filtering if needed (common for all search types)
|
if chunk_filter is not None:
|
||||||
if filtered_doc_ids is not None:
|
results = results.where(chunk_filter)
|
||||||
chunks_df = await results.to_pandas()
|
|
||||||
filtered_chunks_df = chunks_df.loc[
|
|
||||||
chunks_df["document_id"].isin(filtered_doc_ids)
|
|
||||||
].head(limit)
|
|
||||||
return await self._process_search_results(filtered_chunks_df)
|
|
||||||
|
|
||||||
# No filtering needed, apply limit and return
|
|
||||||
results = results.limit(limit)
|
results = results.limit(limit)
|
||||||
return await self._process_search_results(results)
|
return await self._process_search_results(results)
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -179,3 +179,45 @@ async def test_search_filter_with_all_search_types(temp_db_path):
|
||||||
for result in results:
|
for result in results:
|
||||||
assert result.document_uri is not None
|
assert result.document_uri is not None
|
||||||
assert "other.com" in result.document_uri
|
assert "other.com" in result.document_uri
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.vcr()
|
||||||
|
async def test_search_with_filter_returns_full_limit(temp_db_path):
|
||||||
|
"""Regression: filter + limit must return up to `limit` matching chunks
|
||||||
|
even when non-matching chunks would dominate the top-N window.
|
||||||
|
|
||||||
|
Previously the filter path materialized LanceDB's default top-N window
|
||||||
|
(~10), filtered to matching document_ids in pandas, then took `head(limit)`.
|
||||||
|
If the top-N window was dominated by non-matching chunks, the caller got
|
||||||
|
silently fewer results than requested — even when plenty of matching
|
||||||
|
chunks existed further down the ranking. This test puts the target
|
||||||
|
document behind many distractor documents and asserts we still get the
|
||||||
|
requested count back.
|
||||||
|
"""
|
||||||
|
async with HaikuRAG(db_path=temp_db_path, create=True) as client:
|
||||||
|
for i in range(12):
|
||||||
|
await client.create_document(
|
||||||
|
content=(
|
||||||
|
"machine learning neural network deep learning model "
|
||||||
|
"machine learning neural network deep learning model "
|
||||||
|
"machine learning neural network deep learning model"
|
||||||
|
),
|
||||||
|
uri=f"https://distractor.com/doc{i}.html",
|
||||||
|
title=f"Distractor {i}",
|
||||||
|
)
|
||||||
|
|
||||||
|
await client.create_document(
|
||||||
|
content="one passing mention of machine learning here",
|
||||||
|
uri="https://target.com/one.html",
|
||||||
|
title="Target One",
|
||||||
|
)
|
||||||
|
|
||||||
|
results = await client.search(
|
||||||
|
"machine learning",
|
||||||
|
limit=5,
|
||||||
|
search_type="fts",
|
||||||
|
filter="uri LIKE '%target.com%'",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(results) == 1
|
||||||
|
assert results[0].document_uri == "https://target.com/one.html"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue