Skip to content

Commit ceade4c

Browse files
pgustafsPeter Gustafsson
andauthored
fix(file_processors): Preserve Docling chunk structure metadata (#6398)
# What does this PR do? Fixes #6396. Docling returns useful information for each chunk, such as section headings and page numbers. OGX was reading some of this information from the wrong place, so it was lost during file processing. This PR fixes that for: - local Docling; - Docling Serve sync processing; and - Docling Serve async processing. The values are stored as existing chunk attributes. Headings and page numbers are converted to strings so they work with the current vector store search response. For example: ```json { "headings": "Installation > Database setup", "page_numbers": "4, 5" } ``` The `>` separator keeps the heading order clear. A comma inside a heading is not treated as a new heading. The old nested Docling heading format is still supported as a fallback. No new API field is added. Existing user attributes continue to work. Applications can use the filename, heading, and page number to create citations such as: ```text manual.pdf, page 4, "Installation > Database setup" ``` ## Related discussion #6399 proposed returning a separate structured metadata field from vector store search. During review, we agreed to use the existing `attributes` field with string values instead. This PR follows that decision, so it does not depend on #6399. ## Test Plan Run the focused unit tests: ```bash uv run pytest -q \ tests/unit/providers/file_processor/test_docling_serve.py \ tests/unit/providers/file_processor/test_docling_metadata.py ``` Output: ```text 30 passed, 1 warning ``` The warning is an existing `AsyncMock` warning in the IBM SaaS compatibility test. Run the repository checks for the changed files: ```bash uv run pre-commit run --files \ src/ogx/providers/utils/files/structural_metadata.py \ src/ogx/providers/inline/file_processor/docling/_metadata.py \ src/ogx/providers/remote/file_processor/docling_serve/docling_serve.py \ tests/unit/providers/file_processor/test_docling_metadata.py \ tests/unit/providers/file_processor/test_docling_serve.py ``` All checks passed. I also tested this with a running OGX stack using Docling Serve and PGVector. A search result for text on the second page returned: ```json { "file_id": "file-31dffd91a4be4f1196cac053379c9fbe", "filename": "precise-citation.pdf", "headings": "Precise Citations, Chapter Two", "page_numbers": "2", "verification": "citation-backward-compatibility" } ``` This was enough to create the following document, section, and page citation: ```text precise-citation.pdf, page 2, "Precise Citations, Chapter Two" ``` --------- Signed-off-by: Peter Gustafsson <peter.gustafsson6@gmail.com> Co-authored-by: Peter Gustafsson <peter.gustafsson6@gmail.com>
1 parent 59872d4 commit ceade4c

6 files changed

Lines changed: 231 additions & 20 deletions

File tree

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# Copyright (c) The OGX Contributors.
2+
# All rights reserved.
3+
#
4+
# This source code is licensed under the terms described in the LICENSE file in
5+
# the root directory of this source tree.
6+
7+
from typing import Any
8+
9+
from ogx.providers.utils.files.structural_metadata import structural_metadata_as_attributes
10+
11+
12+
def extract_structural_metadata(doc_chunk: Any) -> dict[str, Any]:
13+
chunk_meta = getattr(doc_chunk, "meta", None)
14+
if chunk_meta is None:
15+
return {}
16+
17+
headings = getattr(chunk_meta, "headings", None)
18+
legacy_headings = getattr(doc_chunk, "headings", None)
19+
page_numbers = {
20+
page_number
21+
for doc_item in getattr(chunk_meta, "doc_items", [])
22+
for provenance in (getattr(doc_item, "prov", None) or [])
23+
if (page_number := getattr(provenance, "page_no", None)) is not None
24+
}
25+
metadata: dict[str, Any] = structural_metadata_as_attributes(
26+
headings=headings,
27+
page_numbers=sorted(page_numbers),
28+
)
29+
if not headings and legacy_headings:
30+
metadata["headings"] = legacy_headings
31+
return metadata

src/ogx/providers/inline/file_processor/docling/docling.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
VectorStoreChunkingStrategy,
3535
)
3636

37+
from ._metadata import extract_structural_metadata
3738
from .config import DoclingFileProcessorConfig
3839

3940
log = get_logger(name=__name__, category="providers::file_processors")
@@ -277,7 +278,6 @@ def _create_chunks(
277278
if not text or not text.strip():
278279
continue
279280

280-
headings = getattr(doc_chunk, "headings", None)
281281
chunk_window = f"{i}"
282282

283283
chunk_id = generate_chunk_id(document_id, text, chunk_window)
@@ -286,8 +286,7 @@ def _create_chunks(
286286
"document_id": document_id,
287287
**document_metadata,
288288
}
289-
if headings:
290-
meta["headings"] = headings
289+
meta.update(extract_structural_metadata(doc_chunk))
291290

292291
chunks.append(
293292
Chunk(

src/ogx/providers/remote/file_processor/docling_serve/docling_serve.py

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919

2020
from ogx.log import get_logger
2121
from ogx.providers.utils.files.response import response_body_bytes
22+
from ogx.providers.utils.files.structural_metadata import structural_metadata_as_attributes
2223
from ogx.providers.utils.vector_io.vector_utils import generate_chunk_id
2324
from ogx_api.common.errors import InvalidParameterError
2425
from ogx_api.file_processors import ProcessFileRequest, ProcessFileResponse
@@ -339,9 +340,18 @@ async def _convert_and_chunk(
339340
**document_metadata,
340341
}
341342

342-
headings = raw_chunk.get("meta", {}).get("headings", None)
343-
if headings:
344-
meta["headings"] = headings
343+
legacy_meta = raw_chunk.get("meta") or {}
344+
headings = raw_chunk.get("headings")
345+
legacy_headings = legacy_meta.get("headings")
346+
page_numbers = raw_chunk.get("page_numbers") or legacy_meta.get("page_numbers")
347+
meta.update(
348+
structural_metadata_as_attributes(
349+
headings=headings,
350+
page_numbers=page_numbers,
351+
)
352+
)
353+
if not headings and legacy_headings:
354+
meta["headings"] = legacy_headings
345355

346356
chunks.append(
347357
Chunk(
@@ -418,13 +428,18 @@ async def _convert_and_chunk_async(
418428
**document_metadata,
419429
}
420430

421-
# Extract headings from meta object
422-
headings = None
423-
if hasattr(raw_chunk, "meta") and hasattr(raw_chunk.meta, "headings"):
424-
headings = raw_chunk.meta.headings
425-
426-
if headings:
427-
meta["headings"] = headings
431+
legacy_meta = getattr(raw_chunk, "meta", None)
432+
headings = getattr(raw_chunk, "headings", None)
433+
legacy_headings = getattr(legacy_meta, "headings", None)
434+
page_numbers = getattr(raw_chunk, "page_numbers", None) or getattr(legacy_meta, "page_numbers", None)
435+
meta.update(
436+
structural_metadata_as_attributes(
437+
headings=headings,
438+
page_numbers=page_numbers,
439+
)
440+
)
441+
if not headings and legacy_headings:
442+
meta["headings"] = legacy_headings
428443

429444
chunks.append(
430445
Chunk(
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# Copyright (c) The OGX Contributors.
2+
# All rights reserved.
3+
#
4+
# This source code is licensed under the terms described in the LICENSE file in
5+
# the root directory of this source tree.
6+
7+
from typing import Any
8+
9+
10+
def _attribute_value(value: Any, separator: str) -> str:
11+
if isinstance(value, list | tuple):
12+
return separator.join(str(item) for item in value if str(item))
13+
return str(value) if value is not None else ""
14+
15+
16+
def structural_metadata_as_attributes(*, headings: Any = None, page_numbers: Any = None) -> dict[str, str]:
17+
"""Convert structural chunk metadata to scalar vector-store attributes."""
18+
metadata: dict[str, str] = {}
19+
20+
headings_value = _attribute_value(headings, " > ")
21+
if headings_value:
22+
metadata["headings"] = headings_value
23+
24+
page_numbers_value = _attribute_value(page_numbers, ", ")
25+
if page_numbers_value:
26+
metadata["page_numbers"] = page_numbers_value
27+
28+
return metadata
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
# Copyright (c) The OGX Contributors.
2+
# All rights reserved.
3+
#
4+
# This source code is licensed under the terms described in the LICENSE file in
5+
# the root directory of this source tree.
6+
7+
from types import SimpleNamespace
8+
9+
from ogx.providers.inline.file_processor.docling._metadata import extract_structural_metadata
10+
from ogx.providers.utils.files.structural_metadata import structural_metadata_as_attributes
11+
from ogx_api.vector_io import VectorStoreContent, VectorStoreSearchResponse
12+
13+
14+
def test_extract_structural_metadata_from_native_docling_chunk():
15+
doc_chunk = SimpleNamespace(
16+
headings=["wrong location"],
17+
meta=SimpleNamespace(
18+
headings=["Introduction", "Architecture"],
19+
doc_items=[
20+
SimpleNamespace(prov=[SimpleNamespace(page_no=2), SimpleNamespace(page_no=1)]),
21+
SimpleNamespace(prov=[SimpleNamespace(page_no=2)]),
22+
SimpleNamespace(prov=None),
23+
],
24+
),
25+
)
26+
27+
assert extract_structural_metadata(doc_chunk) == {
28+
"headings": "Introduction > Architecture",
29+
"page_numbers": "1, 2",
30+
}
31+
32+
33+
def test_extract_structural_metadata_omits_empty_values():
34+
doc_chunk = SimpleNamespace(meta=SimpleNamespace(headings=None, doc_items=[]))
35+
36+
assert extract_structural_metadata(doc_chunk) == {}
37+
38+
39+
def test_extract_structural_metadata_preserves_legacy_top_level_headings():
40+
doc_chunk = SimpleNamespace(
41+
headings=["Legacy heading"],
42+
meta=SimpleNamespace(headings=None, doc_items=[]),
43+
)
44+
45+
assert extract_structural_metadata(doc_chunk) == {
46+
"headings": ["Legacy heading"],
47+
}
48+
49+
50+
def test_structural_metadata_attributes_keep_commas_inside_headings():
51+
assert structural_metadata_as_attributes(
52+
headings=["Safety, Security", "Database setup"],
53+
page_numbers=[4, 5],
54+
) == {
55+
"headings": "Safety, Security > Database setup",
56+
"page_numbers": "4, 5",
57+
}
58+
59+
60+
def test_structural_metadata_attributes_preserve_existing_strings():
61+
assert structural_metadata_as_attributes(
62+
headings="Database setup",
63+
page_numbers="4, 5",
64+
) == {
65+
"headings": "Database setup",
66+
"page_numbers": "4, 5",
67+
}
68+
69+
70+
def test_structural_metadata_is_valid_in_vector_store_search_attributes():
71+
metadata = structural_metadata_as_attributes(
72+
headings=["Installation", "Database setup"],
73+
page_numbers=[4, 5],
74+
)
75+
76+
result = VectorStoreSearchResponse(
77+
file_id="file-123",
78+
filename="manual.pdf",
79+
score=1.0,
80+
attributes=metadata,
81+
content=[VectorStoreContent(type="text", text="Database setup instructions")],
82+
)
83+
84+
assert result.attributes == {
85+
"headings": "Installation > Database setup",
86+
"page_numbers": "4, 5",
87+
}

tests/unit/providers/file_processor/test_docling_serve.py

Lines changed: 58 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,30 @@ def _make_httpx_response(json_body: dict, status_code: int = 200) -> httpx.Respo
4242

4343
CHUNK_RESPONSE = {
4444
"chunks": [
45-
{"text": "First chunk of text.", "meta": {"headings": ["Introduction"]}},
46-
{"text": "Second chunk of text.", "meta": {}},
47-
{"text": "Third chunk of text.", "meta": {"headings": ["Conclusion"]}},
45+
{
46+
"filename": "test.pdf",
47+
"chunk_index": 0,
48+
"text": "First chunk of text.",
49+
"headings": ["Introduction"],
50+
"doc_items": ["#/texts/0"],
51+
"page_numbers": [1],
52+
},
53+
{
54+
"filename": "test.pdf",
55+
"chunk_index": 1,
56+
"text": "Second chunk of text.",
57+
"headings": None,
58+
"doc_items": ["#/texts/1"],
59+
"page_numbers": None,
60+
},
61+
{
62+
"filename": "test.pdf",
63+
"chunk_index": 2,
64+
"text": "Third chunk of text.",
65+
"headings": ["Safety, Security", "Conclusion"],
66+
"doc_items": ["#/texts/2"],
67+
"page_numbers": [2, 3],
68+
},
4869
],
4970
}
5071

@@ -206,15 +227,36 @@ async def test_chunk_id_uniqueness(self, processor: DoclingServeFileProcessor, u
206227
ids = [c.chunk_id for c in response.chunks]
207228
assert len(ids) == len(set(ids))
208229

209-
async def test_headings_propagated(self, processor: DoclingServeFileProcessor, upload_file: UploadFile):
230+
async def test_structural_metadata_propagated(self, processor: DoclingServeFileProcessor, upload_file: UploadFile):
210231
request = ProcessFileRequest(chunking_strategy=VectorStoreChunkingStrategyAuto())
211232

212233
with patch("httpx.AsyncClient.post", return_value=_make_httpx_response(CHUNK_RESPONSE)):
213234
response = await processor.process_file(request, file=upload_file)
214235

215-
assert response.chunks[0].metadata["headings"] == ["Introduction"]
236+
assert response.chunks[0].metadata["headings"] == "Introduction"
237+
assert response.chunks[0].metadata["page_numbers"] == "1"
216238
assert "headings" not in response.chunks[1].metadata
217-
assert response.chunks[2].metadata["headings"] == ["Conclusion"]
239+
assert "page_numbers" not in response.chunks[1].metadata
240+
assert response.chunks[2].metadata["headings"] == "Safety, Security > Conclusion"
241+
assert response.chunks[2].metadata["page_numbers"] == "2, 3"
242+
243+
async def test_legacy_nested_headings_keep_existing_list_type(
244+
self, processor: DoclingServeFileProcessor, upload_file: UploadFile
245+
):
246+
request = ProcessFileRequest(chunking_strategy=VectorStoreChunkingStrategyAuto())
247+
legacy_response = {
248+
"chunks": [
249+
{
250+
"text": "Legacy chunk shape.",
251+
"meta": {"headings": ["Legacy heading"]},
252+
}
253+
]
254+
}
255+
256+
with patch("httpx.AsyncClient.post", return_value=_make_httpx_response(legacy_response)):
257+
response = await processor.process_file(request, file=upload_file)
258+
259+
assert response.chunks[0].metadata["headings"] == ["Legacy heading"]
218260

219261
async def test_chunk_window_set(self, processor: DoclingServeFileProcessor, upload_file: UploadFile):
220262
request = ProcessFileRequest(chunking_strategy=VectorStoreChunkingStrategyAuto())
@@ -478,7 +520,14 @@ async def test_local_docker_allows_chunking(self, upload_file: UploadFile):
478520

479521
# Mock submit_chunk() returning a successful job
480522
mock_job = AsyncMock()
481-
mock_chunk = SimpleNamespace(text="Chunk content", meta=SimpleNamespace(headings=None))
523+
mock_chunk = SimpleNamespace(
524+
filename="test.pdf",
525+
chunk_index=0,
526+
text="Chunk content",
527+
headings=["Introduction"],
528+
doc_items=["#/texts/0"],
529+
page_numbers=[1, 2],
530+
)
482531
mock_response = SimpleNamespace(chunks=[mock_chunk])
483532
mock_job.result.return_value = mock_response
484533
mock_instance.submit_chunk.return_value = mock_job
@@ -493,4 +542,6 @@ async def test_local_docker_allows_chunking(self, upload_file: UploadFile):
493542
assert source.stream.getvalue() == b"%PDF-fake-content"
494543
assert result.chunks is not None
495544
assert len(result.chunks) > 0
545+
assert result.chunks[0].metadata["headings"] == "Introduction"
546+
assert result.chunks[0].metadata["page_numbers"] == "1, 2"
496547
assert result.metadata["conversion_method"] == "async"

0 commit comments

Comments
 (0)