Skip to content

Commit 1510e2d

Browse files
author
Peter Gustafsson
committed
fix(file_processors): expose Docling structure as attributes
Serialize headings and page numbers as scalar chunk attributes so vector store search can return them without a separate metadata field. Preserve the existing legacy fallback shape for backward compatibility. Refs #6396 Signed-off-by: Peter Gustafsson <peter.gustafsson6@gmail.com>
1 parent dc222e8 commit 1510e2d

5 files changed

Lines changed: 138 additions & 28 deletions

File tree

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

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,24 +6,26 @@
66

77
from typing import Any
88

9+
from ogx.providers.utils.files.structural_metadata import structural_metadata_as_attributes
10+
911

1012
def extract_structural_metadata(doc_chunk: Any) -> dict[str, Any]:
1113
chunk_meta = getattr(doc_chunk, "meta", None)
1214
if chunk_meta is None:
1315
return {}
1416

15-
metadata: dict[str, Any] = {}
1617
headings = getattr(chunk_meta, "headings", None)
17-
if headings:
18-
metadata["headings"] = headings
19-
18+
legacy_headings = getattr(doc_chunk, "headings", None)
2019
page_numbers = {
2120
page_number
2221
for doc_item in getattr(chunk_meta, "doc_items", [])
2322
for provenance in (getattr(doc_item, "prov", None) or [])
2423
if (page_number := getattr(provenance, "page_no", None)) is not None
2524
}
26-
if page_numbers:
27-
metadata["page_numbers"] = sorted(page_numbers)
28-
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
2931
return metadata

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

Lines changed: 21 additions & 12 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
@@ -344,13 +345,17 @@ async def _convert_and_chunk(
344345
}
345346

346347
legacy_meta = raw_chunk.get("meta") or {}
347-
headings = raw_chunk.get("headings") or legacy_meta.get("headings")
348-
if headings:
349-
meta["headings"] = headings
350-
348+
headings = raw_chunk.get("headings")
349+
legacy_headings = legacy_meta.get("headings")
351350
page_numbers = raw_chunk.get("page_numbers") or legacy_meta.get("page_numbers")
352-
if page_numbers:
353-
meta["page_numbers"] = page_numbers
351+
meta.update(
352+
structural_metadata_as_attributes(
353+
headings=headings,
354+
page_numbers=page_numbers,
355+
)
356+
)
357+
if not headings and legacy_headings:
358+
meta["headings"] = legacy_headings
354359

355360
chunks.append(
356361
Chunk(
@@ -432,13 +437,17 @@ async def _convert_and_chunk_async(
432437
}
433438

434439
legacy_meta = getattr(raw_chunk, "meta", None)
435-
headings = getattr(raw_chunk, "headings", None) or getattr(legacy_meta, "headings", None)
436-
if headings:
437-
meta["headings"] = headings
438-
440+
headings = getattr(raw_chunk, "headings", None)
441+
legacy_headings = getattr(legacy_meta, "headings", None)
439442
page_numbers = getattr(raw_chunk, "page_numbers", None) or getattr(legacy_meta, "page_numbers", None)
440-
if page_numbers:
441-
meta["page_numbers"] = page_numbers
443+
meta.update(
444+
structural_metadata_as_attributes(
445+
headings=headings,
446+
page_numbers=page_numbers,
447+
)
448+
)
449+
if not headings and legacy_headings:
450+
meta["headings"] = legacy_headings
442451

443452
chunks.append(
444453
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

tests/unit/providers/file_processor/test_docling_metadata.py

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
from types import SimpleNamespace
88

99
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
1012

1113

1214
def test_extract_structural_metadata_from_native_docling_chunk():
@@ -23,12 +25,63 @@ def test_extract_structural_metadata_from_native_docling_chunk():
2325
)
2426

2527
assert extract_structural_metadata(doc_chunk) == {
26-
"headings": ["Introduction", "Architecture"],
27-
"page_numbers": [1, 2],
28+
"headings": "Introduction > Architecture",
29+
"page_numbers": "1, 2",
2830
}
2931

3032

3133
def test_extract_structural_metadata_omits_empty_values():
3234
doc_chunk = SimpleNamespace(meta=SimpleNamespace(headings=None, doc_items=[]))
3335

3436
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: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ def _make_httpx_response(json_body: dict, status_code: int = 200) -> httpx.Respo
6161
"filename": "test.pdf",
6262
"chunk_index": 2,
6363
"text": "Third chunk of text.",
64-
"headings": ["Conclusion"],
64+
"headings": ["Safety, Security", "Conclusion"],
6565
"doc_items": ["#/texts/2"],
6666
"page_numbers": [2, 3],
6767
},
@@ -232,12 +232,30 @@ async def test_structural_metadata_propagated(self, processor: DoclingServeFileP
232232
with patch("httpx.AsyncClient.post", return_value=_make_httpx_response(CHUNK_RESPONSE)):
233233
response = await processor.process_file(request, file=upload_file)
234234

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

242260
async def test_chunk_window_set(self, processor: DoclingServeFileProcessor, upload_file: UploadFile):
243261
request = ProcessFileRequest(chunking_strategy=VectorStoreChunkingStrategyAuto())
@@ -514,6 +532,6 @@ async def test_local_docker_allows_chunking(self, upload_file: UploadFile):
514532

515533
assert result.chunks is not None
516534
assert len(result.chunks) > 0
517-
assert result.chunks[0].metadata["headings"] == ["Introduction"]
518-
assert result.chunks[0].metadata["page_numbers"] == [1, 2]
535+
assert result.chunks[0].metadata["headings"] == "Introduction"
536+
assert result.chunks[0].metadata["page_numbers"] == "1, 2"
519537
assert result.metadata["conversion_method"] == "async"

0 commit comments

Comments
 (0)