Dedupe picture chunks and skip small pictures at chunking

This commit is contained in:
Yiorgis Gozadinos 2026-07-09 10:24:32 +03:00
parent 0c8ff570d6
commit 43a580afb3
No known key found for this signature in database
6 changed files with 180 additions and 6 deletions

View file

@ -1,6 +1,11 @@
# Changelog
## [Unreleased]
### Changed
- Duplicate images within a document produce a single picture chunk.
- Pictures smaller than `processing.min_picture_size` pixels on the smaller side (default 64) no longer become picture chunks; `0` disables the filter.
## [0.64.0] - 2026-07-08
### Added

View file

@ -259,6 +259,8 @@ Per-image failures (404, timeout, oversized, unreadable) leave that picture as a
| `description` | on | yes | yes |
| `image` (default) | on | yes | no |
Not every picture becomes a picture chunk. Identical picture bytes within a document produce a single chunk, so a watermark or logo repeated on every page embeds once. Pictures smaller than `processing.min_picture_size` pixels on their smaller side (default 64, `0` disables) are skipped entirely. Filtered pictures keep their bytes in `document_items`, so context expansion and vision QA still see them.
Use `none` when you don't need picture content (e.g. very large reference manuals where RAM is tight). Use `description` to weave VLM-generated text into chunk content and keep bytes for later. Use `image` (default) to keep bytes without paying the VLM cost. The prompt is configurable under `prompts.picture_description`. See [Prompts](prompts.md).
```yaml
@ -302,9 +304,9 @@ The Embedder column below is driven by `embeddings.model.multimodal`, not the pr
|---|---|---|---|
| `none` | any | text only (caption/surrounding) | none |
| `image` | text-only | text only (caption/surrounding) | none |
| `image` | multimodal | text only | one per picture, vector = image embedding |
| `image` | multimodal | text only | one per distinct picture, vector = image embedding |
| `description` | text-only | text + descriptions | none |
| `description` | multimodal | text + descriptions | one per picture, vector = image embedding |
| `description` | multimodal | text + descriptions | one per distinct picture, vector = image embedding |
**What QA receives** at search time:

View file

@ -1,4 +1,5 @@
import asyncio
import io
import logging
import tempfile
from pathlib import Path
@ -14,7 +15,7 @@ from haiku.rag.store.models.chunk import Chunk
from haiku.rag.store.models.document_item import _picture_description_text
if TYPE_CHECKING:
from docling_core.types.doc.document import DoclingDocument
from docling_core.types.doc.document import DoclingDocument, PictureItem
from haiku.rag.embeddings import EmbedderWrapper
@ -173,11 +174,13 @@ def _merge_picture_chunks(
text_chunks: list[Chunk],
document_id: str | None,
existing_picture_data: dict[str, bytes] | None,
min_picture_size: int,
) -> list[Chunk]:
picture_chunks = build_picture_chunks(
docling_document,
document_id=document_id,
existing_picture_data=existing_picture_data,
min_picture_size=min_picture_size,
)
if not picture_chunks:
@ -235,22 +238,49 @@ async def chunk(
text_chunks,
document_id,
existing_picture_data,
config.processing.min_picture_size,
)
def _min_picture_side(picture: "PictureItem", data: bytes) -> float | None:
"""The picture's smaller pixel dimension, or None when it can't be
determined. Uses ``ImageRef.size`` when the live image is present; falls
back to a PIL header read of the bytes (rebuild path, where picture URIs
have been stripped)."""
if picture.image is not None:
return min(picture.image.size.width, picture.image.size.height)
from PIL import Image as PILImage
from PIL import UnidentifiedImageError
try:
with PILImage.open(io.BytesIO(data)) as img:
return min(img.size)
except UnidentifiedImageError:
return None
def build_picture_chunks(
docling_document: "DoclingDocument",
*,
document_id: str | None = None,
existing_picture_data: dict[str, bytes] | None = None,
min_picture_size: int = 0,
) -> list[Chunk]:
"""Emit one synthetic ``Chunk`` per ``PictureItem`` with available bytes.
"""Emit one synthetic ``Chunk`` per distinct ``PictureItem`` with available
bytes.
Bytes come from ``picture.image.uri`` (live data URI on a freshly-converted
docling) or from ``existing_picture_data`` keyed by ``self_ref`` (snapshot
taken before a delete-and-re-extract cycle, when the live docling has had
its picture URIs stripped). Pictures with no available bytes are skipped.
Pictures whose bytes were already seen in this document are skipped the
first occurrence carries the chunk, so a watermark repeated on every page
embeds once. Pictures whose smaller side is under ``min_picture_size``
pixels are skipped entirely (``0`` disables the size filter; pictures
whose size can't be determined are kept).
The bytes ride on ``Chunk._picture_data`` (a PrivateAttr not serialized)
so ``embed_chunks`` can route them through ``embed_image``. The
``order`` field is left at its default (0); the caller (``chunk()``)
@ -262,6 +292,7 @@ def build_picture_chunks(
)
existing = existing_picture_data or {}
seen: set[bytes] = set()
chunks: list[Chunk] = []
for picture in docling_document.pictures:
@ -271,6 +302,15 @@ def build_picture_chunks(
if picture_data is None:
continue
if picture_data in seen:
continue
seen.add(picture_data)
if min_picture_size > 0:
side = _min_picture_side(picture, picture_data)
if side is not None and side < min_picture_size:
continue
text = extract_item_text(picture, docling_document) or ""
page_numbers: list[int] = []

View file

@ -206,6 +206,11 @@ class ProcessingConfig(BaseModel):
- ``"image"``: docling generates picture images and stores them in
``document_items.picture_data``; no VLM runs at ingest.
"""
min_picture_size: int = 64
"""Minimum pixel size (smaller side) for a picture to become a picture
chunk. Smaller pictures icons, bullets, decorative graphics are not
embedded or indexed; their bytes stay in ``document_items`` for context
expansion. ``0`` keeps all pictures."""
extract_pdf_attachments: bool = True
"""When a PDF carries `/EmbeddedFiles`, ingest each attachment as a separate
Document linked back to the wrapper via ``metadata.parent_uri``. Cap depth

View file

@ -281,6 +281,8 @@ async def test_embed_only_preserves_picture_vectors(temp_db_path, monkeypatch):
model=EmbeddingModelConfig(provider="ollama", name="stub", vector_dim=4)
)
)
# The fixture picture is 8x8; disable the size filter so it still chunks.
config.processing.min_picture_size = 0
docling_doc = _docling_doc_with_picture()
@ -514,6 +516,124 @@ def test_build_picture_chunks_skips_pictures_without_bytes():
assert chunks == []
def _doc_with_picture_images(*images):
"""DoclingDocument with one paragraph and one PictureItem per PIL image."""
from docling_core.types.doc.document import DoclingDocument, ImageRef
from docling_core.types.doc.labels import DocItemLabel
doc = DoclingDocument(name="pics")
doc.add_text(label=DocItemLabel.PARAGRAPH, text="Hello world")
for img in images:
doc.add_picture(image=ImageRef.from_pil(img, dpi=72))
return doc
def test_build_picture_chunks_dedupes_identical_bytes():
"""Identical picture bytes within a document produce one chunk — the
first occurrence. A watermark repeated on every page embeds once."""
from PIL import Image as PILImageModule
from haiku.rag.client.processing import build_picture_chunks
red = PILImageModule.new("RGB", (100, 100), "red")
blue = PILImageModule.new("RGB", (100, 100), "blue")
doc = _doc_with_picture_images(red, red, blue, red)
chunks = build_picture_chunks(doc, document_id="doc-1")
refs = [c.metadata["doc_item_refs"][0] for c in chunks]
assert refs == ["#/pictures/0", "#/pictures/2"]
def test_build_picture_chunks_skips_small_pictures():
"""Pictures whose smaller side is under min_picture_size are not chunked."""
from PIL import Image as PILImageModule
from haiku.rag.client.processing import build_picture_chunks
icon = PILImageModule.new("RGB", (16, 16), "red")
figure = PILImageModule.new("RGB", (100, 100), "blue")
banner = PILImageModule.new("RGB", (200, 16), "green")
doc = _doc_with_picture_images(icon, figure, banner)
chunks = build_picture_chunks(doc, document_id="doc-1", min_picture_size=64)
assert [c.metadata["doc_item_refs"][0] for c in chunks] == ["#/pictures/1"]
def test_build_picture_chunks_measures_snapshot_bytes():
"""Rebuild path: picture.image is None, so size comes from a PIL header
read of the snapshot bytes existing DBs shed small pictures on rebuild."""
import io
from PIL import Image as PILImageModule
from haiku.rag.client.processing import build_picture_chunks
icon_png = io.BytesIO()
PILImageModule.new("RGB", (16, 16), "red").save(icon_png, format="PNG")
doc = _doc_with_picture_images(PILImageModule.new("RGB", (16, 16), "red"))
for picture in doc.pictures:
picture.image = None
chunks = build_picture_chunks(
doc,
document_id="doc-1",
existing_picture_data={"#/pictures/0": icon_png.getvalue()},
min_picture_size=64,
)
assert chunks == []
def test_build_picture_chunks_keeps_unmeasurable_bytes():
"""Bytes PIL can't parse are kept — the filter only drops what it can
measure."""
from haiku.rag.client.processing import build_picture_chunks
from tests.store.test_document_items import _docling_doc_with_picture
doc = _docling_doc_with_picture()
for picture in doc.pictures:
picture.image = None
chunks = build_picture_chunks(
doc,
document_id="doc-1",
existing_picture_data={"#/pictures/0": b"not-an-image"},
min_picture_size=64,
)
assert len(chunks) == 1
@pytest.mark.asyncio
async def test_chunk_filters_small_pictures_by_config(monkeypatch):
"""``chunk()`` applies ``processing.min_picture_size`` — with the default
config, icon-sized pictures don't become picture chunks."""
from haiku.rag.client.processing import chunk
from haiku.rag.embeddings import EmbedderWrapper
from haiku.rag.store.models.chunk import Chunk
from tests.store.test_document_items import _docling_doc_with_picture
class StubMultimodalEmbedder(EmbedderWrapper):
supports_images = True
def __init__(self):
super().__init__(embedder=None, vector_dim=4)
class StubChunker:
async def chunk(self, document):
return [Chunk(content="text", metadata={"doc_item_refs": ["#/texts/0"]})]
monkeypatch.setattr(
"haiku.rag.chunkers.get_chunker", lambda *a, **kw: StubChunker()
)
doc = _docling_doc_with_picture() # 8x8 picture, below the 64px default
chunks = await chunk(AppConfig(), doc, embedder=StubMultimodalEmbedder())
assert [c.content for c in chunks] == ["text"]
assert not any("picture" in (c.metadata or {}).get("labels", []) for c in chunks)
@pytest.mark.asyncio
async def test_chunk_interleaves_picture_in_structural_order(monkeypatch):
"""``chunk()`` merges text and picture chunks by their first
@ -554,7 +674,7 @@ async def test_chunk_interleaves_picture_in_structural_order(monkeypatch):
from docling_core.types.doc.labels import DocItemLabel
from PIL import Image as PILImageModule
img = PILImageModule.new("RGB", (8, 8), "blue")
img = PILImageModule.new("RGB", (64, 64), "blue")
doc = DoclingDocument(name="ordered")
doc.add_text(label=DocItemLabel.PARAGRAPH, text="A")
doc.add_text(label=DocItemLabel.PARAGRAPH, text="B")
@ -676,6 +796,8 @@ async def test_ingest_emits_picture_chunks_with_multimodal_embedder(
model=EmbeddingModelConfig(provider="ollama", name="stub", vector_dim=4)
)
)
# The fixture picture is 8x8; disable the size filter so it still chunks.
config.processing.min_picture_size = 0
async with HaikuRAG(temp_db_path, config=config, create=True) as rag:
chunks = await rag.chunk(docling_doc)

View file

@ -218,7 +218,7 @@ def test_merge_picture_chunks_no_pictures_returns_text_chunks():
doc = _doc_without_pictures()
text_chunks = [Chunk(content="a"), Chunk(content="b")]
result = _merge_picture_chunks(doc, text_chunks, None, None)
result = _merge_picture_chunks(doc, text_chunks, None, None, 0)
assert result is text_chunks
assert [c.order for c in result] == [0, 1]