dedup picture-only chunks at the search-result layer

This commit is contained in:
Yiorgis Gozadinos 2026-05-04 13:31:15 +03:00
parent 33e462d987
commit e8d89aa035
No known key found for this signature in database
3 changed files with 92 additions and 7 deletions

View file

@ -68,6 +68,7 @@ async def search(
)
results = [SearchResult.from_chunk(chunk, score) for chunk, score in chunk_results]
results = _dedup_picture_chunks(results)
if include_images:
await _populate_image_data(client, results)
@ -75,6 +76,32 @@ async def search(
return results
def _dedup_picture_chunks(results: list[SearchResult]) -> list[SearchResult]:
"""Collapse duplicate picture-only chunks to one result per ``self_ref``.
A single picture can produce two chunks for the same self_ref: one whose
vector is the text embedding of the picture's description, and one whose
vector is the image embedding of the picture's bytes. Both can rank for
the same query. When two results share a single picture self_ref as
their only ref, keep the higher-scoring one. Wider chunks that span the
picture plus surrounding items pass through untouched.
"""
seen: dict[tuple[str | None, str], int] = {}
keep: list[bool] = [True] * len(results)
for i, r in enumerate(results):
if len(r.doc_item_refs) == 1 and r.doc_item_refs[0].startswith("#/pictures/"):
key = (r.document_id, r.doc_item_refs[0])
prior = seen.get(key)
if prior is None:
seen[key] = i
elif r.score > results[prior].score:
keep[prior] = False
seen[key] = i
else:
keep[i] = False
return [r for r, k in zip(results, keep) if k]
async def _populate_image_data(client: "HaikuRAG", results: list[SearchResult]) -> None:
"""Attach base64 picture bytes to ``SearchResult.image_data`` in-place.

View file

@ -53,21 +53,19 @@ def load_yaml_config(path: Path) -> dict:
def _translate_legacy_picture_fields(data: dict) -> None:
"""Map pre-A4 picture knobs onto ``processing.pictures``.
"""Map legacy picture knobs onto ``processing.pictures``.
Pre-A4 the same intent was expressed by two booleans on
Older configs expressed the same intent through two booleans on
``conversion_options``: ``generate_picture_images`` and
``picture_description.enabled``. Translation, in priority order:
- ``picture_description.enabled = true`` (regardless of the image flag)
``pictures = "description"``. Mirrors the original behavior where
enabling the VLM implicitly forced docling to produce picture bytes.
``pictures = "description"``.
- ``generate_picture_images = true`` (and no description) ``"image"``.
- both false / missing no translation; default ``"none"`` applies.
If ``pictures`` is already set on the loaded YAML it wins users who
have migrated keep their explicit choice. Mutates ``data`` in-place
and emits one warning per legacy field encountered.
If ``pictures`` is already set on the loaded YAML it wins. Mutates
``data`` in-place and emits one warning per legacy field encountered.
"""
processing = data.get("processing")
if not isinstance(processing, dict):

View file

@ -440,3 +440,63 @@ async def test_search_with_bytes_query_raises_for_text_only_embedder(
async with HaikuRAG(temp_db_path, create=True) as rag:
with pytest.raises(ValueError, match="multimodal embedder"):
await rag.search(b"\x89PNG\r\n\x1a\n")
def _picture_only_result(
self_ref: str, score: float, document_id: str = "doc-1"
) -> SearchResult:
return SearchResult(
content="x",
score=score,
chunk_id=f"chunk-{self_ref}-{score}",
document_id=document_id,
doc_item_refs=[self_ref],
labels=["picture"],
)
def test_dedup_keeps_higher_scoring_picture_chunk():
"""Two results referencing the same single picture self_ref collapse
to the one with the higher score."""
from haiku.rag.client.search import _dedup_picture_chunks
text_chunk = _picture_only_result("#/pictures/0", score=0.7)
pic_chunk = _picture_only_result("#/pictures/0", score=0.9)
other = _picture_only_result("#/pictures/1", score=0.6)
deduped = _dedup_picture_chunks([text_chunk, pic_chunk, other])
assert len(deduped) == 2
chosen = next(r for r in deduped if r.doc_item_refs == ["#/pictures/0"])
assert chosen.score == 0.9
assert any(r.doc_item_refs == ["#/pictures/1"] for r in deduped)
def test_dedup_preserves_wider_chunks_referencing_same_picture():
"""A wider chunk that contains the picture plus surrounding items
is independent signal keep it alongside a picture-only chunk."""
from haiku.rag.client.search import _dedup_picture_chunks
pic_only = _picture_only_result("#/pictures/0", score=0.9)
wider = SearchResult(
content="surrounding paragraph text and a figure",
score=0.7,
chunk_id="wider",
document_id="doc-1",
doc_item_refs=["#/texts/3", "#/pictures/0", "#/texts/4"],
labels=["text", "picture", "text"],
)
deduped = _dedup_picture_chunks([pic_only, wider])
assert len(deduped) == 2
def test_dedup_does_not_collapse_across_documents():
"""Same self_ref in different documents is different content."""
from haiku.rag.client.search import _dedup_picture_chunks
a = _picture_only_result("#/pictures/0", score=0.5, document_id="doc-1")
b = _picture_only_result("#/pictures/0", score=0.9, document_id="doc-2")
deduped = _dedup_picture_chunks([a, b])
assert len(deduped) == 2