186 lines
6.6 KiB
Python
186 lines
6.6 KiB
Python
import re
|
|
from io import BytesIO
|
|
from typing import TYPE_CHECKING
|
|
|
|
from haiku.rag.chunkers.base import DocumentChunker
|
|
from haiku.rag.config import AppConfig, Config
|
|
from haiku.rag.providers.docling_serve import DoclingServeClient
|
|
from haiku.rag.store.models.chunk import Chunk, ChunkMetadata
|
|
|
|
if TYPE_CHECKING:
|
|
from docling_core.types.doc.document import DoclingDocument
|
|
|
|
# Pattern to parse refs like "#/texts/5" or "#/tables/0"
|
|
REF_PATTERN = re.compile(r"^#/(\w+)/(\d+)$")
|
|
|
|
|
|
def _resolve_label_from_document(ref: str, document: "DoclingDocument") -> str | None:
|
|
"""Resolve the label for a doc_item ref by looking it up in the document.
|
|
|
|
The docling-serve API only returns ref strings in doc_items, not labels.
|
|
This function resolves actual labels from the DoclingDocument.
|
|
See: https://github.com/docling-project/docling-serve/issues/448
|
|
|
|
Args:
|
|
ref: JSON pointer reference like "#/texts/5" or "#/tables/0"
|
|
document: The DoclingDocument to look up the item in
|
|
|
|
Returns:
|
|
The label string if found, None otherwise
|
|
"""
|
|
match = REF_PATTERN.match(ref)
|
|
if not match:
|
|
return None
|
|
|
|
collection_name = match.group(1)
|
|
index = int(match.group(2))
|
|
|
|
collection = getattr(document, collection_name, None)
|
|
if collection is None or index >= len(collection):
|
|
return None
|
|
|
|
item = collection[index]
|
|
return getattr(item, "label", None)
|
|
|
|
|
|
class DoclingServeChunker(DocumentChunker):
|
|
"""Remote document chunker using docling-serve API.
|
|
|
|
Sends DoclingDocument JSON to docling-serve for chunking. Supports both hybrid
|
|
and hierarchical chunking strategies via remote API.
|
|
|
|
Args:
|
|
config: Application configuration containing docling-serve settings.
|
|
"""
|
|
|
|
def __init__(self, config: AppConfig = Config):
|
|
self.config = config
|
|
self.client = DoclingServeClient(
|
|
base_url=config.providers.docling_serve.base_url,
|
|
api_key=config.providers.docling_serve.api_key,
|
|
)
|
|
self.chunker_type = config.processing.chunker_type
|
|
|
|
def _build_chunking_data(self) -> dict[str, str | list[str]]:
|
|
"""Build form data for chunking request."""
|
|
opts = self.config.processing.conversion_options
|
|
data: dict[str, str | list[str]] = {
|
|
"convert_do_ocr": str(opts.do_ocr).lower(),
|
|
"convert_force_ocr": str(opts.force_ocr).lower(),
|
|
"convert_ocr_engine": opts.ocr_engine,
|
|
"chunking_max_tokens": str(self.config.processing.chunk_size),
|
|
"chunking_tokenizer": self.config.processing.chunking_tokenizer,
|
|
"chunking_merge_peers": str(
|
|
self.config.processing.chunking_merge_peers
|
|
).lower(),
|
|
"chunking_use_markdown_tables": str(
|
|
self.config.processing.chunking_use_markdown_tables
|
|
).lower(),
|
|
}
|
|
if opts.ocr_lang:
|
|
data["convert_ocr_lang"] = opts.ocr_lang
|
|
return data
|
|
|
|
async def _call_chunk_api(self, document: "DoclingDocument") -> list[dict]:
|
|
"""Call docling-serve chunking API and return raw chunk data.
|
|
|
|
Args:
|
|
document: The DoclingDocument to be split into chunks.
|
|
|
|
Returns:
|
|
List of chunk dictionaries from API response.
|
|
|
|
Raises:
|
|
ValueError: If chunking fails or service is unavailable.
|
|
"""
|
|
# Determine endpoint based on chunker_type
|
|
if self.chunker_type == "hierarchical":
|
|
endpoint = "/v1/chunk/hierarchical/file/async"
|
|
else:
|
|
endpoint = "/v1/chunk/hybrid/file/async"
|
|
|
|
# Export document to JSON
|
|
doc_json = document.model_dump_json()
|
|
doc_bytes = doc_json.encode("utf-8")
|
|
|
|
# Prepare multipart request with DoclingDocument JSON
|
|
files = {"files": ("document.json", BytesIO(doc_bytes), "application/json")}
|
|
data = self._build_chunking_data()
|
|
|
|
result = await self.client.submit_and_poll(
|
|
endpoint=endpoint,
|
|
files=files,
|
|
data=data,
|
|
name="document",
|
|
)
|
|
|
|
return result.get("chunks", [])
|
|
|
|
async def chunk(self, document: "DoclingDocument") -> list[Chunk]:
|
|
"""Split the document into chunks with metadata via docling-serve.
|
|
|
|
Extracts structured metadata from the API response including:
|
|
- doc_item_refs: JSON pointer references to DocItems (e.g., "#/texts/5")
|
|
- headings: Section heading hierarchy
|
|
- labels: Semantic labels for each doc_item
|
|
- page_numbers: Page numbers where content appears
|
|
|
|
Args:
|
|
document: The DoclingDocument to be split into chunks.
|
|
|
|
Returns:
|
|
List of Chunk containing content and structured metadata.
|
|
|
|
Raises:
|
|
ValueError: If chunking fails or service is unavailable.
|
|
"""
|
|
if document is None:
|
|
return []
|
|
|
|
raw_chunks = await self._call_chunk_api(document)
|
|
result: list[Chunk] = []
|
|
|
|
for chunk in raw_chunks:
|
|
text = chunk.get("text", "")
|
|
|
|
# doc_items from docling-serve is a list of ref strings like ["#/texts/1", "#/tables/0"]
|
|
doc_items = chunk.get("doc_items", [])
|
|
doc_item_refs: list[str] = []
|
|
labels: list[str] = []
|
|
|
|
for item in doc_items:
|
|
if isinstance(item, str):
|
|
# docling-serve returns refs as strings directly
|
|
doc_item_refs.append(item)
|
|
# Resolve label from the document using the ref
|
|
label = _resolve_label_from_document(item, document)
|
|
if label:
|
|
labels.append(label)
|
|
elif isinstance(item, dict):
|
|
# Handle dict format if API ever returns it
|
|
if "self_ref" in item:
|
|
doc_item_refs.append(item["self_ref"])
|
|
if "label" in item:
|
|
labels.append(item["label"])
|
|
|
|
# Get headings directly from chunk
|
|
headings = chunk.get("headings")
|
|
|
|
# Get page numbers directly from chunk
|
|
page_numbers = chunk.get("page_numbers", [])
|
|
|
|
chunk_metadata = ChunkMetadata(
|
|
doc_item_refs=doc_item_refs,
|
|
headings=headings,
|
|
labels=labels,
|
|
page_numbers=sorted(page_numbers) if page_numbers else [],
|
|
)
|
|
result.append(
|
|
Chunk(
|
|
content=text,
|
|
metadata=chunk_metadata.model_dump(),
|
|
order=len(result),
|
|
)
|
|
)
|
|
|
|
return result
|