Coverage for src/qdrant_loader/core/chunking/strategy/docling_strategy.py: 97%
38 statements
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-20 10:15 +0000
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-20 10:15 +0000
1"""Structure-aware chunking strategy backed by docling's ``HybridChunker``.
3This is the chunking half of full Option B: instead of re-parsing a markdown string,
4it consumes the structured ``DoclingDocument`` the docling conversion engine produced
5(carried on :attr:`Document.converted_document`) and chunks it natively — so heading
6paths, page spans, bounding boxes and element labels survive into the payload via the
7engine-neutral ``metadata.structure`` block.
9It composes the seam in :mod:`qdrant_loader.core.chunking.docling`:
10``build_chunker`` (the docling ``HybridChunker`` facade) plus the docling-free
11:class:`ChunkDocumentMapper` (chunks -> chunk ``Document``s). The chunk-size budget
12and contextual-embedding toggle are read from ``chunking.strategies.docling`` (falling
13back to the *embedding* settings), and the token counter is aligned to the embedding
14model's tokenizer — see :meth:`DoclingChunkingStrategy._build_config`.
16NLP enrichment parity with the markdown path is provided by the shared
17:class:`~qdrant_loader.core.text_processing.chunk_enricher.ChunkEnricher`, which writes
18the entities/topics/key_phrases (+ enhanced) keys onto each chunk ``Document``. One
19intentional divergence: this path front-loads a per-*document* LDA topic model
20(``ChunkEnricher.fit_topics``) before enriching, whereas markdown still trains a
21per-*chunk* model — see :meth:`chunk_document`. Parity is the floor; docling is allowed
22to exceed it on topic quality.
23"""
25from __future__ import annotations
27from typing import TYPE_CHECKING
29from qdrant_loader.core.chunking.docling import (
30 ChunkDocumentMapper,
31 ChunkerKind,
32 ChunkingConfig,
33 ChunkingError,
34 TableSerialization,
35 build_chunker,
36)
37from qdrant_loader.core.chunking.strategy.base_strategy import BaseChunkingStrategy
38from qdrant_loader.core.text_processing.chunk_enricher import ChunkEnricher
39from qdrant_loader.utils.logging import LoggingConfig
41logger = LoggingConfig.get_logger(__name__)
43if TYPE_CHECKING:
44 from qdrant_loader.config import Settings
45 from qdrant_loader.config.chunking import DoclingStrategyConfig
46 from qdrant_loader.config.embedding import EmbeddingConfig
47 from qdrant_loader.core.chunking.docling import DocumentChunker
48 from qdrant_loader.core.document import Document
51class DoclingChunkingStrategy(BaseChunkingStrategy):
52 """Chunks a docling-converted document via its structured ``DoclingDocument``."""
54 # This strategy enriches via ChunkEnricher (SemanticAnalyzer), not the base
55 # TextProcessor — so don't let BaseChunkingStrategy load a second spaCy model.
56 _uses_base_text_processor = False
58 def __init__(self, settings: Settings) -> None:
59 super().__init__(settings)
60 self._config: ChunkingConfig = self._build_config(
61 settings.global_config.chunking.strategies.docling,
62 settings.global_config.embedding,
63 )
64 self._chunker: DocumentChunker = build_chunker(
65 ChunkerKind.DOCLING, self._config
66 )
67 self._mapper = ChunkDocumentMapper(self._config)
68 self._enricher = ChunkEnricher(settings)
70 @staticmethod
71 def _build_config(
72 docling: DoclingStrategyConfig, embedding: EmbeddingConfig
73 ) -> ChunkingConfig:
74 """Bridge the YAML knobs into the frozen engine config.
76 The chunk-size budget is ``chunking.strategies.docling.max_tokens`` when set,
77 otherwise the embedding model's ``max_tokens_per_chunk``. The tokenizer
78 identity (how tokens are counted) always comes from ``embedding.tokenizer`` —
79 the override changes the budget, never the counter.
80 """
81 max_tokens = (
82 docling.max_tokens
83 if docling.max_tokens is not None
84 else embedding.max_tokens_per_chunk
85 )
86 return ChunkingConfig.from_embedding(
87 tokenizer=embedding.tokenizer,
88 max_tokens=max_tokens,
89 include_context_in_embed=docling.include_context_in_embed,
90 table_serialization=TableSerialization(docling.table_serialization),
91 )
93 def chunk_document(self, document: Document) -> list[Document]:
94 """Chunk the structured artifact, then map chunks into chunk ``Document``s.
96 Requires the structured ``DoclingDocument`` produced by the docling engine.
97 Its absence is a wiring bug, not a soft-degrade case, so it fails loud rather
98 than silently emitting one giant chunk.
99 """
100 converted = document.converted_document
101 if converted is None:
102 raise ChunkingError(
103 "docling chunking requires a converted DoclingDocument, but "
104 f"document {document.id!r} carries none — was it converted with the "
105 "docling engine? (conversion_method="
106 f"{document.metadata.get('conversion_method')!r})"
107 )
109 chunks = self._chunker.chunk(converted)
111 # Configuration-driven safety limit, in parity with the other strategies: a
112 # huge document must not emit unbounded chunks and overwhelm embedding/upsert.
113 max_chunks = self.settings.global_config.chunking.max_chunks_per_document
114 if len(chunks) > max_chunks:
115 logger.warning(
116 f"Docling chunking produced {len(chunks)} chunks, limiting to "
117 f"{max_chunks} per max_chunks_per_document. Consider raising "
118 f"max_chunks_per_document or the token budget. Document: {document.title}"
119 )
120 chunks = chunks[:max_chunks]
122 documents = self._mapper.to_documents(chunks, document)
124 # Front-load the topic model on this document's chunks, so each chunk's
125 # topics are inferred against one model instead of a degenerate per-chunk
126 # single-document LDA. Must run before the enrich loop below.
127 #
128 # NOTE: this intentionally diverges from the markdown path, which still
129 # trains a per-chunk LDA.
130 self._enricher.fit_topics([chunk_doc.content for chunk_doc in documents])
132 # NLP enrichment parity with the markdown path: the shared ChunkEnricher
133 # writes entities/topics/key_phrases (+ enhanced when enabled), or the
134 # empty-shape keys when disabled. Runs on the stored chunk content.
135 for chunk_doc in documents:
136 chunk_doc.metadata.update(
137 self._enricher.enrich(chunk_doc.content, doc_id=chunk_doc.id)
138 )
140 return documents
142 def shutdown(self) -> None:
143 """Release the enricher's analyzer resources (spaCy/LDA)."""
144 if getattr(self, "_enricher", None) is not None:
145 self._enricher.shutdown()
147 def __del__(self) -> None:
148 """Release per-document NLP resources on GC.
150 The service builds a fresh strategy per document (see
151 ``ChunkingService._get_strategy``), so without this the spaCy/LDA model
152 each instance loads would linger until GC ran its finalizer-free path.
153 Mirrors the markdown strategy's ``__del__`` so both paths free the
154 enricher the same way.
155 """
156 self.shutdown()