Coverage for src/lilbee/data/extract/chunk.py: 100%
55 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 11:46 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 11:46 +0000
1"""Text chunking with optional heading-aware and topic-aware splitting."""
3from __future__ import annotations
5from dataclasses import replace
6from typing import TYPE_CHECKING
8from lilbee.core.config import active_config
9from lilbee.data.types import EmbeddingBackendName, TokenizerBackendName
11if TYPE_CHECKING:
12 from xberg import ChunkingConfig, ChunkSizing, EmbeddingConfig, TableChunkingMode
14# Char->token ratio for English.
15CHARS_PER_TOKEN = 4
17_SEMANTIC_CHUNKER = "semantic"
18_MARKDOWN_CHUNKER = "markdown"
21def _char_budget() -> tuple[int, int]:
22 """Return (max_chars, max_overlap) in characters from the token-based cfg."""
23 config = active_config()
24 max_chars = config.chunk_size * CHARS_PER_TOKEN
25 max_overlap = min(config.chunk_overlap * CHARS_PER_TOKEN, max_chars // 2)
26 return max_chars, max_overlap
29def _size_params() -> tuple[int, int, ChunkSizing | None]:
30 """Return (max, overlap, sizing) for the plain and heading chunkers.
32 With ``cfg.token_sizing`` on, the budget is a raw token count and ``sizing``
33 routes to lilbee's registered tokenizer backend, so ``chunk_size`` is a real
34 token ceiling. Otherwise the character heuristic with no sizing (xberg's default
35 character sizer). The semantic chunker does not use this -- it sizes by
36 characters and ignores ChunkSizing."""
37 config = active_config()
38 if config.token_sizing:
39 from xberg import ChunkSizing
41 overlap = min(config.chunk_overlap, config.chunk_size // 2)
42 return (
43 config.chunk_size,
44 overlap,
45 ChunkSizing(type="tokenizer", model=TokenizerBackendName.LILBEE),
46 )
47 max_chars, max_overlap = _char_budget()
48 return max_chars, max_overlap, None
51def _semantic_embedding_config() -> EmbeddingConfig:
52 """EmbeddingConfig for semantic chunking. Boundary-detection embeddings route to
53 lilbee's embedder, registered as xberg's plugin backend in
54 ``lilbee.data.extract.backends.registry`` (embedding binding), so the model that
55 vectorizes chunks for retrieval is the one that decides where they split."""
56 from xberg import EmbeddingConfig, EmbeddingModelType
58 model = EmbeddingModelType.plugin(EmbeddingBackendName.LILBEE)
59 return EmbeddingConfig(model=model)
62def _table_chunking() -> TableChunkingMode | None:
63 """Header-repeating table splits when table extraction is on, else None for
64 xberg's default.
66 REPEAT_HEADER carries the header row into every piece of a long table, so
67 no chunk holds headerless rows.
68 """
69 config = active_config()
70 if not config.table_extraction:
71 return None
72 from xberg import TableChunkingMode
74 return TableChunkingMode.REPEAT_HEADER
77def build_chunking_config(*, use_semantic: bool = True) -> ChunkingConfig:
78 """Build an xberg ChunkingConfig from the current cfg."""
79 from xberg import ChunkingConfig
81 config = active_config()
82 if use_semantic and config.semantic_chunking:
83 # The semantic chunker sizes by characters and ignores ChunkSizing, so it
84 # stays on the character budget regardless of cfg.token_sizing.
85 max_chars, max_overlap = _char_budget()
86 chunking = ChunkingConfig(
87 chunker_type=_SEMANTIC_CHUNKER,
88 embedding=_semantic_embedding_config(),
89 topic_threshold=config.topic_threshold,
90 max_characters=max_chars,
91 overlap=max_overlap,
92 )
93 else:
94 max_size, max_overlap, sizing = _size_params()
95 chunking = ChunkingConfig(
96 max_characters=max_size,
97 overlap=max_overlap,
98 sizing=sizing,
99 )
100 # table_chunking has no "unset" value on xberg's frozen ChunkingConfig, so the
101 # field is left at its default rather than overwritten with None.
102 mode = _table_chunking()
103 return chunking if mode is None else replace(chunking, table_chunking=mode)
106def chunk_text(
107 text: str,
108 *,
109 mime_type: str = "text/plain",
110 heading_context: bool = False,
111 use_semantic: bool = True,
112) -> list[str]:
113 """Split text into chunks; heading_context wins over use_semantic wins over char-budget."""
114 if not text or not text.strip():
115 return []
117 from xberg import ChunkingConfig, ExtractionConfig
119 from .xberg import extract_document
121 if heading_context:
122 max_size, max_overlap, sizing = _size_params()
123 chunking = ChunkingConfig(
124 max_characters=max_size,
125 overlap=max_overlap,
126 sizing=sizing,
127 chunker_type=_MARKDOWN_CHUNKER,
128 prepend_heading_context=True,
129 )
130 else:
131 chunking = build_chunking_config(use_semantic=use_semantic)
133 config = ExtractionConfig(chunking=chunking)
134 doc = extract_document(text.encode("utf-8"), mime_type, config=config)
135 if doc.chunks:
136 return [c.content for c in doc.chunks]
137 return []