diff --git a/haiku_rag_slim/haiku/rag/client/documents.py b/haiku_rag_slim/haiku/rag/client/documents.py index 670686fd..23cdf688 100644 --- a/haiku_rag_slim/haiku/rag/client/documents.py +++ b/haiku_rag_slim/haiku/rag/client/documents.py @@ -118,7 +118,12 @@ async def _store_document_with_chunks( Handles versioning/rollback on failure. """ chunks = await ensure_chunks_embedded(client._config, chunks, client.embedder) - items = await asyncio.to_thread(extract_items, "", docling_document) + items = await asyncio.to_thread( + extract_items, + "", + docling_document, + fast_picture_text=client._config.processing.fast_picture_text, + ) async with client.store._write_lock: versions = await client.store.current_table_versions() @@ -175,7 +180,11 @@ async def _update_document_with_chunks( items: list[DocumentItem] | None = None if docling_document is not None: items = await asyncio.to_thread( - extract_items, document.id, docling_document, existing_picture_data + extract_items, + document.id, + docling_document, + existing_picture_data, + fast_picture_text=client._config.processing.fast_picture_text, ) async with client.store._write_lock: @@ -282,8 +291,13 @@ async def _store_documents_with_chunks( for _, chunks, _ in prepared ] + fast_picture_text = client._config.processing.fast_picture_text + def _extract_all_items(): - return [extract_items("", d) for _, _, d in prepared] + return [ + extract_items("", d, fast_picture_text=fast_picture_text) + for _, _, d in prepared + ] all_item_lists = await asyncio.to_thread(_extract_all_items) diff --git a/haiku_rag_slim/haiku/rag/client/processing.py b/haiku_rag_slim/haiku/rag/client/processing.py index 198f7736..25deac36 100644 --- a/haiku_rag_slim/haiku/rag/client/processing.py +++ b/haiku_rag_slim/haiku/rag/client/processing.py @@ -173,11 +173,13 @@ def _merge_picture_chunks( text_chunks: list[Chunk], document_id: str | None, existing_picture_data: dict[str, bytes] | None, + fast_picture_text: bool, ) -> list[Chunk]: picture_chunks = build_picture_chunks( docling_document, document_id=document_id, existing_picture_data=existing_picture_data, + fast_picture_text=fast_picture_text, ) if not picture_chunks: @@ -235,6 +237,7 @@ async def chunk( text_chunks, document_id, existing_picture_data, + config.processing.fast_picture_text, ) @@ -243,6 +246,7 @@ def build_picture_chunks( *, document_id: str | None = None, existing_picture_data: dict[str, bytes] | None = None, + fast_picture_text: bool = True, ) -> list[Chunk]: """Emit one synthetic ``Chunk`` per ``PictureItem`` with available bytes. @@ -271,7 +275,12 @@ def build_picture_chunks( if picture_data is None: continue - text = extract_item_text(picture, docling_document) or "" + text = ( + extract_item_text( + picture, docling_document, fast_picture_text=fast_picture_text + ) + or "" + ) page_numbers: list[int] = [] for p in picture.prov: diff --git a/haiku_rag_slim/haiku/rag/config/models.py b/haiku_rag_slim/haiku/rag/config/models.py index 08622db7..ee65a663 100644 --- a/haiku_rag_slim/haiku/rag/config/models.py +++ b/haiku_rag_slim/haiku/rag/config/models.py @@ -191,6 +191,14 @@ class ProcessingConfig(BaseModel): """When a PDF carries `/EmbeddedFiles`, ingest each attachment as a separate Document linked back to the wrapper via ``metadata.parent_uri``. Cap depth at 3 to bound nested-attachment recursion.""" + fast_picture_text: bool = True + """Picture chunk/item text uses the cheap caption accessor + (``FloatingItem.caption_text``) instead of rendering each picture through a + full-document Markdown serializer. The serializer re-validates the entire + DoclingDocument per picture (bbox clamping over every table cell), which is + O(pictures x document) at ingest. Set False to restore exact + ``export_to_markdown`` text. Only governs ingestion; rebuild and store + migrations always use the fast path.""" auto_title: bool = False title_model: ModelConfig = Field( default_factory=lambda: ModelConfig( diff --git a/haiku_rag_slim/haiku/rag/store/models/document_item.py b/haiku_rag_slim/haiku/rag/store/models/document_item.py index f86080e7..518a11c4 100644 --- a/haiku_rag_slim/haiku/rag/store/models/document_item.py +++ b/haiku_rag_slim/haiku/rag/store/models/document_item.py @@ -53,7 +53,12 @@ def _decode_picture_bytes(item: "PictureItem") -> bytes | None: return base64.b64decode(encoded, validate=False) -def extract_item_text(item: "NodeItem", docling_doc: "DoclingDocument") -> str | None: +def extract_item_text( + item: "NodeItem", + docling_doc: "DoclingDocument", + *, + fast_picture_text: bool = True, +) -> str | None: """Extract text content from a DocItem. Handles different item types: @@ -62,9 +67,15 @@ def extract_item_text(item: "NodeItem", docling_doc: "DoclingDocument") -> str | - PictureItem: Prefer the VLM description (when picture_description is on) so pictures carry meaningful prose into chunk text and survive ``expand_with_items``' ``if item.text:`` filter; otherwise fall back to - a placeholder markdown export (no base64). + the picture's caption. + + With ``fast_picture_text`` (the default), a description-less picture's text + comes from ``caption_text`` — O(captions). When False, it is rendered via + ``export_to_markdown`` with an empty image placeholder, which builds a + full-document serializer (re-validating the whole DoclingDocument) per + picture; the resulting text is still just the caption for an + annotation-less picture. """ - from docling_core.types.doc.base import ImageRefMode from docling_core.types.doc.document import PictureItem, TableItem if text := getattr(item, "text", None): @@ -73,6 +84,10 @@ def extract_item_text(item: "NodeItem", docling_doc: "DoclingDocument") -> str | if isinstance(item, PictureItem): if description := _picture_description_text(item): return description + if fast_picture_text: + return item.caption_text(docling_doc) + from docling_core.types.doc.base import ImageRefMode + return item.export_to_markdown( docling_doc, image_mode=ImageRefMode.PLACEHOLDER, @@ -96,6 +111,8 @@ def extract_items( document_id: str, docling_doc: "DoclingDocument", existing_picture_data: dict[str, bytes] | None = None, + *, + fast_picture_text: bool = True, ) -> list[DocumentItem]: """Extract document items from a DoclingDocument for the items table. @@ -120,7 +137,10 @@ def extract_items( label = getattr(item, "label", None) label_str = str(label.value) if hasattr(label, "value") else str(label or "") - text = extract_item_text(item, docling_doc) or "" + text = ( + extract_item_text(item, docling_doc, fast_picture_text=fast_picture_text) + or "" + ) page_numbers: list[int] = [] if prov := getattr(item, "prov", None): diff --git a/tests/conftest.py b/tests/conftest.py index b5924d74..a44602a1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -35,7 +35,7 @@ logging.getLogger("vcr.cassette").setLevel(logging.WARNING) @pytest.fixture(scope="session") def qa_corpus() -> list[dict[str, str]]: corpus_path = Path(__file__).parent / "data" / "qa_corpus.json" - with open(corpus_path) as f: + with open(corpus_path, encoding="utf-8") as f: return json.load(f) diff --git a/tests/test_document.py b/tests/test_document.py index eb41a9a4..ef7f6059 100644 --- a/tests/test_document.py +++ b/tests/test_document.py @@ -281,6 +281,42 @@ def test_set_docling_with_page_images(): assert "1" in pages +def test_extract_item_text_picture_modes(monkeypatch): + """fast_picture_text (default) returns the caption via caption_text and + never builds a Markdown serializer; fast_picture_text=False matches the + placeholder export.""" + from docling_core.types.doc.base import ImageRefMode + from docling_core.types.doc.document import ( + DoclingDocument, + ImageRef, + PictureItem, + ) + from docling_core.types.doc.labels import DocItemLabel + from PIL import Image as PILImageModule + + from haiku.rag.store.models.document_item import extract_item_text + + img = PILImageModule.new("RGB", (8, 8), "blue") + doc = DoclingDocument(name="caption_test") + caption = doc.add_text(label=DocItemLabel.CAPTION, text="A figure caption") + doc.add_picture(image=ImageRef.from_pil(img, dpi=72), caption=caption) + picture = doc.pictures[0] + + # Slow path equals the placeholder markdown export. + expected_slow = picture.export_to_markdown( + doc, image_mode=ImageRefMode.PLACEHOLDER, image_placeholder="" + ) + assert extract_item_text(picture, doc, fast_picture_text=False) == expected_slow + + # Fast path returns the caption and must not construct a serializer. + def _boom(*args, **kwargs): + raise AssertionError("export_to_markdown should not run on the fast path") + + monkeypatch.setattr(PictureItem, "export_to_markdown", _boom) + assert extract_item_text(picture, doc) == "A figure caption" + assert extract_item_text(picture, doc, fast_picture_text=True) == "A figure caption" + + def test_get_page_images(): """get_page_images returns requested pages from docling_pages blob.""" import json