Coverage for src/qdrant_loader/core/chunking/docling/config.py: 97%
37 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"""Engine-agnostic chunking configuration.
3Frozen dataclasses describing *our* chunking knobs — never docling types. The
4tokenizer factory in :mod:`.tokenizer` is the single place that turns this into a
5docling tokenizer, which keeps the chunker behind the seam in :mod:`.chunker`.
7The defaults encode the chunking design: size the token budget against the embedding
8model's own tokenizer, let HybridChunker merge undersized peers, optionally embed the
9heading-path-prefixed text, and stamp a ``chunk_schema_version`` so a future re-index
10is detectable.
11"""
13from __future__ import annotations
15import dataclasses
16from dataclasses import dataclass, field
17from enum import StrEnum
18from typing import Any
21class TokenizerKind(StrEnum):
22 """Which docling tokenizer wrapper backs the token budget.
24 The string values double as the type-safe parse target for a YAML config
25 field, mirroring :class:`~..conversion.config.ConversionProfile`.
26 """
28 OPENAI = "openai" # tiktoken — pairs with an OpenAI-style embedding model
29 HUGGINGFACE = "huggingface" # transformers AutoTokenizer for a HF model id
32class TableSerialization(StrEnum):
33 """How table items are rendered into chunk text.
35 The string values double as the type-safe parse target for the YAML field
36 ``chunking.strategies.docling.table_serialization``.
37 """
39 # docling's chunking default: "row, column = value" triplets — more
40 # semantically meaningful embeddings than grid layouts (per docling's docs)
41 TRIPLETS = "triplets"
42 # GitHub-flavored markdown rows — better LLM/display readability; very wide
43 # tables may overflow the token budget (docling#3428)
44 MARKDOWN = "markdown"
47@dataclass(frozen=True, slots=True)
48class TokenizerConfig:
49 """How to count tokens, aligned to the *embedding* model.
51 ``HybridChunker`` requires a real tokenizer to size chunks; counting against a
52 tokenizer unrelated to the embedder is the character-budget mistake this layer
53 avoids. ``model`` is a tiktoken encoding name for ``OPENAI`` or a HF model id for
54 ``HUGGINGFACE``.
55 """
57 kind: TokenizerKind = TokenizerKind.OPENAI
58 model: str = "cl100k_base"
59 max_tokens: int = 512 # the embedding model's per-chunk budget (a hard ceiling)
60 # True when this is a *counting proxy*, not the embedding model's own tokenizer
61 # (the model exposes none — e.g. Ollama). Counts are approximate; the factory
62 # warns once so the choice is observable, never silent.
63 approximate: bool = False
66@dataclass(frozen=True, slots=True)
67class ChunkingConfig:
68 """The complete, immutable chunking configuration for one chunker instance."""
70 tokenizer: TokenizerConfig = field(default_factory=TokenizerConfig)
72 # ── HybridChunker behaviour ──
73 merge_peers: bool = True # recombine undersized adjacent chunks sharing headings
74 delim: str = "\n" # serialization delimiter between merged doc items
75 # how table items become chunk text; YAML-exposed via
76 # ``chunking.strategies.docling.table_serialization``
77 table_serialization: TableSerialization = TableSerialization.TRIPLETS
79 # ── contract shaping ──
80 # Contextual embedding: when on, the chunk's contextualized text (heading_path
81 # + body) becomes the embedded text AND the stored content. Opt-in (default off) via
82 # ``chunking.strategies.docling.include_context_in_embed``; the seam is wired through
83 # the chunker (contextualize) and the mapper (content = embed_text).
84 include_context_in_embed: bool = False
85 # Caveat: contextualize() can push a chunk past max_tokens (HybridChunker only
86 # guarantees the budget for the bare body). When True, the chunker logs a warning on
87 # overflow rather than failing — the embedder may truncate oversized input.
88 enforce_token_budget: bool = True
89 chunk_schema_version: str = "1" # lets a future re-index be detected
91 @classmethod
92 def from_embedding(
93 cls, *, tokenizer: str, max_tokens: int, **overrides: Any
94 ) -> ChunkingConfig:
95 """Derive a config from the loader's embedding settings.
97 ``tokenizer`` is the ``embedding.tokenizer`` string the loader already
98 carries; ``max_tokens`` is its ``max_tokens_per_chunk``. Top-level overrides
99 replace whole fields (pass a built :class:`TokenizerConfig`, not its parts).
100 """
101 baseline = cls(tokenizer=cls._resolve_tokenizer(tokenizer, max_tokens))
102 return dataclasses.replace(baseline, **overrides) if overrides else baseline
104 @staticmethod
105 def _resolve_tokenizer(tokenizer: str, max_tokens: int) -> TokenizerConfig:
106 """Map an ``embedding.tokenizer`` string onto a :class:`TokenizerConfig`.
108 ``"none"`` (the Ollama-local default, which exposes no tokenizer) falls back
109 to tiktoken ``cl100k_base`` as an *approximate counting proxy* — flagged so
110 the factory warns once, never a silent guess. A slashed value (``org/model``)
111 is a HF model id; anything else is a tiktoken encoding name, and is treated as
112 the embedding model's real tokenizer (not approximate).
113 """
114 if not tokenizer or tokenizer == "none":
115 return TokenizerConfig(
116 kind=TokenizerKind.OPENAI,
117 model="cl100k_base",
118 max_tokens=max_tokens,
119 approximate=True,
120 )
121 if "/" in tokenizer:
122 return TokenizerConfig(
123 kind=TokenizerKind.HUGGINGFACE, model=tokenizer, max_tokens=max_tokens
124 )
125 return TokenizerConfig(
126 kind=TokenizerKind.OPENAI, model=tokenizer, max_tokens=max_tokens
127 )