Merge pull request #467 from ggozad/perf/item-text-serializer-reuse

Reuse one MarkdownDocSerializer per document in item extraction
This commit is contained in:
Yiorgis Gozadinos 2026-06-24 15:13:06 +03:00 committed by GitHub
commit af66af45a0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 151 additions and 12 deletions

View file

@ -5,6 +5,7 @@
- zstd compression uses a fresh `zstandard` compressor/decompressor per call instead of shared module-level singletons, fixing process segfaults when ingester workers compress documents concurrently (Python < 3.14).
- Ingestion compresses the DoclingDocument structure directly from the in-memory dict, removing one full-size serialized copy from per-document peak memory.
- Document item extraction builds one `MarkdownDocSerializer` per document (reused across tables) and derives description-less picture text from captions, instead of constructing a serializer per picture and table.
## [0.61.0] - 2026-06-23

View file

@ -1,5 +1,6 @@
import base64
from typing import TYPE_CHECKING
from collections.abc import Callable
from typing import TYPE_CHECKING, Any
from pydantic import BaseModel
@ -53,18 +54,33 @@ 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 _picture_caption_text(item: "PictureItem", docling_doc: "DoclingDocument") -> str:
"""Join a picture's caption texts with spaces, preserving word boundaries."""
return " ".join(
text
for caption in item.captions
if (text := caption.resolve(docling_doc).text.strip())
)
def extract_item_text(
item: "NodeItem",
docling_doc: "DoclingDocument",
*,
get_serializer: Callable[[], Any] | None = None,
) -> str | None:
"""Extract text content from a DocItem.
Handles different item types:
- TextItem, SectionHeaderItem, etc.: Use .text attribute
- TableItem: Use export_to_markdown() for table content
- TableItem: serialize to markdown. ``get_serializer`` supplies a reused
``MarkdownDocSerializer`` (see ``extract_items``); when absent a one-off
serializer is built so direct calls keep working.
- 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 text.
"""
from docling_core.types.doc.base import ImageRefMode
from docling_core.types.doc.document import PictureItem, TableItem
if text := getattr(item, "text", None):
@ -73,15 +89,19 @@ def extract_item_text(item: "NodeItem", docling_doc: "DoclingDocument") -> str |
if isinstance(item, PictureItem):
if description := _picture_description_text(item):
return description
return item.export_to_markdown(
docling_doc,
image_mode=ImageRefMode.PLACEHOLDER,
image_placeholder="",
)
return _picture_caption_text(item, docling_doc)
if isinstance(item, TableItem):
try:
return item.export_to_markdown(docling_doc)
if get_serializer is None:
from docling_core.transforms.serializer.markdown import (
MarkdownDocSerializer,
)
serializer = MarkdownDocSerializer(doc=docling_doc)
else:
serializer = get_serializer()
return serializer.serialize(item=item).text
except Exception:
pass
@ -116,11 +136,23 @@ def extract_items(
existing = existing_picture_data or {}
items: list[DocumentItem] = []
serializer: Any = None
def get_serializer() -> Any:
nonlocal serializer
if serializer is None:
from docling_core.transforms.serializer.markdown import (
MarkdownDocSerializer,
)
serializer = MarkdownDocSerializer(doc=docling_doc)
return serializer
for position, (item, level) in enumerate(docling_doc.iterate_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, get_serializer=get_serializer) or ""
page_numbers: list[int] = []
if prov := getattr(item, "prov", None):

View file

@ -140,6 +140,112 @@ class TestExtractItemText:
assert items == []
def _doc_with_captioned_picture(*captions: str):
from docling_core.types.doc.document import DoclingDocument, ImageRef
from docling_core.types.doc.labels import DocItemLabel
from PIL import Image
doc = DoclingDocument(name="pics")
caption_items = [doc.add_text(label=DocItemLabel.CAPTION, text=c) for c in captions]
img = ImageRef.from_pil(Image.new("RGB", (8, 8), "red"), dpi=72)
doc.add_picture(image=img, caption=caption_items[0] if caption_items else None)
pic = doc.pictures[0]
for extra in caption_items[1:]:
pic.captions.append(extra.get_ref())
return doc, pic
def _doc_with_tables(n: int):
from docling_core.types.doc.document import DoclingDocument, TableCell, TableData
doc = DoclingDocument(name="tables")
for _ in range(n):
cells = [
TableCell(
text=f"r{r}c{c}",
row_span=1,
col_span=1,
start_row_offset_idx=r,
end_row_offset_idx=r + 1,
start_col_offset_idx=c,
end_col_offset_idx=c + 1,
)
for r in range(2)
for c in range(2)
]
doc.add_table(data=TableData(num_rows=2, num_cols=2, table_cells=cells))
return doc
class TestExtractItemTextPictures:
"""Description-less pictures derive text from captions, without a serializer."""
def test_multiple_captions_space_joined(self):
doc, pic = _doc_with_captioned_picture("First", "Second")
assert extract_item_text(pic, doc) == "First Second"
def test_description_wins_over_captions(self):
from docling_core.types.doc.document import DescriptionMetaField, PictureMeta
doc, pic = _doc_with_captioned_picture("A caption")
pic.meta = PictureMeta(description=DescriptionMetaField(text="A red square."))
assert extract_item_text(pic, doc) == "A red square."
def test_picture_path_does_not_export_markdown(self, monkeypatch):
from docling_core.types.doc.document import PictureItem
doc, pic = _doc_with_captioned_picture("Only caption")
def _boom(*args, **kwargs):
raise AssertionError("picture path must not build a serializer")
monkeypatch.setattr(PictureItem, "export_to_markdown", _boom)
assert extract_item_text(pic, doc) == "Only caption"
def test_picture_path_never_requests_serializer(self):
doc, pic = _doc_with_captioned_picture("Only caption")
def _explode():
raise AssertionError("picture path must not request a serializer")
assert extract_item_text(pic, doc, get_serializer=_explode) == "Only caption"
class TestExtractItemsTableSerializer:
"""Table text is unchanged; one serializer is reused across the whole pass."""
def test_table_text_matches_export_to_markdown(self):
doc = _doc_with_tables(1)
expected = doc.tables[0].export_to_markdown(doc)
items = extract_items("doc-1", doc)
table_items = [i for i in items if i.label == "table"]
assert len(table_items) == 1
assert table_items[0].text == expected
def test_one_serializer_built_for_multiple_tables(self, monkeypatch):
import docling_core.transforms.serializer.markdown as md
count = {"n": 0}
base = md.MarkdownDocSerializer
class Counting(base):
def __init__(self, *args, **kwargs):
count["n"] += 1
super().__init__(*args, **kwargs)
doc = _doc_with_tables(3)
monkeypatch.setattr(md, "MarkdownDocSerializer", Counting)
items = extract_items("doc-1", doc)
assert sum(1 for i in items if i.label == "table") == 3
assert count["n"] == 1
def test_direct_table_call_builds_one_off_serializer(self):
doc = _doc_with_tables(1)
expected = doc.tables[0].export_to_markdown(doc)
assert extract_item_text(doc.tables[0], doc) == expected
@pytest.mark.asyncio
class TestDocumentItemRepository:
async def test_create_and_get_range(self, temp_db_path):