Coverage for src/lilbee/data/extract/chunk.py: 100%

91 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-11-06 08:17 +0000

1"""Text chunking with optional heading-aware and topic-aware splitting.""" 

2 

3from __future__ import annotations 

4 

5from dataclasses import replace 

6from typing import TYPE_CHECKING 

7 

8from lilbee.core.config import active_config 

9from lilbee.data.types import EmbeddingBackendName, TokenizerBackendName 

10 

11if TYPE_CHECKING: 

12 from xberg import ( 

13 ChunkingConfig, 

14 ChunkSizing, 

15 EmbeddingConfig, 

16 HeadingContext, 

17 TableChunkingMode, 

18 ) 

19 

20# Char->token ratio for English. 

21CHARS_PER_TOKEN = 4 

22 

23_SEMANTIC_CHUNKER = "semantic" 

24_MARKDOWN_CHUNKER = "markdown" 

25# xberg's default sizer: chunk_size counts characters. 

26_CHARACTER_SIZING = "characters" 

27# Markdown heading path rendered into a chunk: "# Setup > ## Install". 

28_HEADING_MARK = "#" 

29_BREADCRUMB_SEPARATOR = " > " 

30 

31 

32def _embed_token_cap() -> int | None: 

33 """Tokens the embedder truncates one input to; None when it has no fixed cap.""" 

34 # circular: chunk -> app.services -> retrieval.embedder -> chunk via CHARS_PER_TOKEN 

35 from lilbee.app.services import get_services 

36 

37 return get_services().provider.embed_token_cap() 

38 

39 

40def token_sizing_in_effect(cap: int | None) -> bool: 

41 """Whether chunks are sized in the embedder's tokens: opted in, or its window binds. 

42 

43 The window binds when *cap* is below the character budget ``chunk_size`` 

44 allows, so a character-sized chunk could exceed it at embedding time. 

45 """ 

46 config = active_config() 

47 if config.token_sizing: 

48 return True 

49 return cap is not None and cap < config.chunk_size * CHARS_PER_TOKEN 

50 

51 

52def _char_budget() -> tuple[int, int]: 

53 """Return (max_chars, max_overlap) in characters from the token-based cfg.""" 

54 config = active_config() 

55 max_chars = config.chunk_size * CHARS_PER_TOKEN 

56 max_overlap = min(config.chunk_overlap * CHARS_PER_TOKEN, max_chars // 2) 

57 return max_chars, max_overlap 

58 

59 

60def _tokenizer_sizing() -> ChunkSizing: 

61 """xberg sizing through lilbee's tokenizer backend, bound to the provider on demand. 

62 

63 xberg fails extraction when a sizing names an unregistered tokenizer, so the 

64 bind precedes every sizing that routes to it. The bind is a no-op once the 

65 current provider holds the binding. 

66 """ 

67 from xberg import ChunkSizing 

68 

69 from lilbee.app.services import get_services 

70 

71 from .backends import BackendKind, bind_backend 

72 

73 bind_backend(BackendKind.TOKENIZER, get_services().provider) 

74 return ChunkSizing(type="tokenizer", model=TokenizerBackendName.LILBEE) 

75 

76 

77def _size_params() -> tuple[int, int, ChunkSizing | str]: 

78 """Return (max, overlap, sizing) for the plain and heading chunkers. 

79 

80 Under token sizing (``cfg.token_sizing``, or an embed window below the 

81 character budget) the budget is a token count no larger than the embedder's 

82 cap and ``sizing`` routes to lilbee's tokenizer backend, so no chunk loses 

83 text at embedding time. Otherwise the character heuristic with xberg's 

84 default character sizer. The semantic chunker does not use this -- it sizes 

85 by characters and ignores ChunkSizing.""" 

86 config = active_config() 

87 cap = _embed_token_cap() 

88 if not token_sizing_in_effect(cap): 

89 max_chars, max_overlap = _char_budget() 

90 return max_chars, max_overlap, _CHARACTER_SIZING 

91 max_tokens = config.chunk_size if cap is None else min(config.chunk_size, cap) 

92 overlap = min(config.chunk_overlap, max_tokens // 2) 

93 return max_tokens, overlap, _tokenizer_sizing() 

94 

95 

96def _semantic_embedding_config() -> EmbeddingConfig: 

97 """EmbeddingConfig for semantic chunking. Boundary-detection embeddings route to 

98 lilbee's embedder, registered as xberg's plugin backend in 

99 ``lilbee.data.extract.backends.registry`` (embedding binding), so the model that 

100 vectorizes chunks for retrieval is the one that decides where they split.""" 

101 from xberg import EmbeddingConfig, EmbeddingModelType 

102 

103 model = EmbeddingModelType.plugin(EmbeddingBackendName.LILBEE) 

104 return EmbeddingConfig(model=model) 

105 

106 

107def _table_chunking() -> TableChunkingMode | None: 

108 """Header-repeating table splits when table extraction is on, else None for 

109 xberg's default. 

110 

111 REPEAT_HEADER carries the header row into every piece of a long table, so 

112 no chunk holds headerless rows. 

113 """ 

114 config = active_config() 

115 if not config.table_extraction: 

116 return None 

117 from xberg import TableChunkingMode 

118 

119 return TableChunkingMode.REPEAT_HEADER 

120 

121 

122def build_chunking_config(*, use_semantic: bool = True) -> ChunkingConfig: 

123 """Build an xberg ChunkingConfig from the current cfg.""" 

124 from xberg import ChunkingConfig 

125 

126 config = active_config() 

127 if use_semantic and config.semantic_chunking: 

128 # The semantic chunker sizes by characters and ignores ChunkSizing, so it 

129 # stays on the character budget regardless of cfg.token_sizing. 

130 max_chars, max_overlap = _char_budget() 

131 chunking = ChunkingConfig( 

132 chunker_type=_SEMANTIC_CHUNKER, 

133 embedding=_semantic_embedding_config(), 

134 topic_threshold=config.topic_threshold, 

135 max_characters=max_chars, 

136 overlap=max_overlap, 

137 ) 

138 else: 

139 max_size, max_overlap, sizing = _size_params() 

140 chunking = ChunkingConfig( 

141 max_characters=max_size, 

142 overlap=max_overlap, 

143 sizing=sizing, 

144 ) 

145 # table_chunking has no "unset" value on xberg's frozen ChunkingConfig, so the 

146 # field is left at its default rather than overwritten with None. 

147 mode = _table_chunking() 

148 return chunking if mode is None else replace(chunking, table_chunking=mode) 

149 

150 

151def chunk_text( 

152 text: str, 

153 *, 

154 mime_type: str = "text/plain", 

155 heading_context: bool = False, 

156 use_semantic: bool = True, 

157) -> list[str]: 

158 """Split text into chunks; heading_context wins over use_semantic wins over char-budget.""" 

159 if not text or not text.strip(): 

160 return [] 

161 

162 from xberg import ChunkingConfig, ExtractionConfig 

163 

164 from .xberg import extract_document 

165 

166 if heading_context: 

167 max_size, max_overlap, sizing = _size_params() 

168 chunking = ChunkingConfig( 

169 max_characters=max_size, 

170 overlap=max_overlap, 

171 sizing=sizing, 

172 chunker_type=_MARKDOWN_CHUNKER, 

173 ) 

174 else: 

175 chunking = build_chunking_config(use_semantic=use_semantic) 

176 

177 config = ExtractionConfig(chunking=chunking) 

178 doc = extract_document(text.encode("utf-8"), mime_type, config=config) 

179 if not doc.chunks: 

180 return [] 

181 if heading_context: 

182 return [_with_heading_breadcrumb(c.content, c.metadata.heading_context) for c in doc.chunks] 

183 return [c.content for c in doc.chunks] 

184 

185 

186def _with_heading_breadcrumb(content: str, context: HeadingContext | None) -> str: 

187 """Prefix a markdown chunk with its heading path, e.g. ``# Setup > ## Install``. 

188 

189 xberg's ``render_heading_breadcrumb`` is Rust-only; the Python binding exposes 

190 the headings as the chunk's ``metadata.heading_context``. 

191 """ 

192 if context is None or not context.headings: 

193 return content 

194 breadcrumb = _BREADCRUMB_SEPARATOR.join( 

195 f"{_HEADING_MARK * h.level} {h.text}" for h in context.headings 

196 ) 

197 return f"{breadcrumb}\n\n{content}" 

198 

199 

200class ChunkLimitError(Exception): 

201 """One file produced more chunks than ``cfg.max_chunks_per_file`` allows.""" 

202 

203 def __init__(self, count: int, limit: int, member: str | None = None) -> None: 

204 prefix = f"{member}: " if member else "" 

205 super().__init__( 

206 f"{prefix}{count} chunks exceed the per-file limit of {limit}; " 

207 f"raise max_chunks_per_file (0 = no limit), then retry skipped files" 

208 ) 

209 self.count = count 

210 self.member = member 

211 self.limit = limit 

212 

213 

214def enforce_chunk_limit(count: int) -> None: 

215 """Refuse a file whose *count* chunks exceed the per-file limit; a limit of 0 accepts any.""" 

216 limit = active_config().max_chunks_per_file 

217 if limit and count > limit: 

218 raise ChunkLimitError(count, limit)