Coverage for src/lilbee/retrieval/embedder.py: 100%

110 statements  

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

1"""Thin wrapper around LLM provider embeddings API.""" 

2 

3import logging 

4import threading 

5 

6import numpy as np 

7 

8from lilbee.core.config import Config, cfg 

9from lilbee.core.vectors import Vector 

10from lilbee.data.extract.chunk import CHARS_PER_TOKEN 

11from lilbee.modelhub.install_state import InstallState, install_state 

12from lilbee.modelhub.registry import ModelRegistry 

13from lilbee.providers.base import LLMProvider 

14from lilbee.providers.model_ref import ProviderModelRef, parse_model_ref 

15from lilbee.retrieval.embedding_profiles import EmbeddingProfile, resolve_embedding_profile 

16from lilbee.runtime.progress import DetailedProgressCallback, EmbedEvent, EventType, noop_callback 

17 

18log = logging.getLogger(__name__) 

19 

20 

21def _name_base(ref: ProviderModelRef) -> str: 

22 return ref.name.split(":")[0].lower().replace(" ", "-") 

23 

24 

25def _remote_sees_model(ref: ProviderModelRef, provider: LLMProvider) -> bool: 

26 try: 

27 available = provider.list_models() 

28 except Exception: 

29 log.debug("provider list_models failed during availability check", exc_info=True) 

30 return False 

31 base = _name_base(ref) 

32 return any(base in m.lower().replace(" ", "-") for m in available) 

33 

34 

35def is_model_installed(model: str) -> bool: 

36 """True if *model* loads from the registry or from a loose GGUF file. 

37 

38 Provider-free by design: paint paths need an installed check that cannot 

39 build the services container (a cold build eager-starts the worker pool). 

40 """ 

41 return install_state(model, ModelRegistry(cfg.models_dir)) is not InstallState.MISSING 

42 

43 

44def is_model_available(model: str, provider: LLMProvider) -> bool: 

45 """Return True if *model* resolves via *provider* or the native registry. 

46 

47 Remote-prefixed refs (``ollama/`` and API providers) skip the native 

48 probe since they resolve through the SDK backend at call time. 

49 """ 

50 if not model: 

51 return False 

52 ref = parse_model_ref(model) 

53 if _remote_sees_model(ref, provider): 

54 return True 

55 if ref.is_remote: 

56 return False 

57 return is_model_installed(model) 

58 

59 

60class Embedder: 

61 """Embedding wrapper: truncates, batches, validates vectors, and counts truncations.""" 

62 

63 def __init__(self, config: Config, provider: LLMProvider) -> None: 

64 self._config = config 

65 self._provider = provider 

66 self._truncated_total = 0 

67 self._truncated_lock = threading.Lock() 

68 

69 @property 

70 def embed_char_budget(self) -> int: 

71 """Effective char limit, never below the chunker's max chunk size. 

72 

73 ``max_embed_chars`` guards the embed model's context; clamping it up to 

74 ``chunk_size * CHARS_PER_TOKEN`` stops a finished full-budget chunk from 

75 silently losing its tail to a limit set below what the chunker emits. 

76 """ 

77 return max(self._config.max_embed_chars, self._config.chunk_size * CHARS_PER_TOKEN) 

78 

79 @property 

80 def batch_char_budget(self) -> int: 

81 """Per-request char cap: a full packed batch of maximum-size chunks. 

82 

83 The target sequences-per-request is ``embed_batch_sequences`` (tunable to 

84 keep a multi-GPU fleet's batching slots full); the engine still re-splits 

85 to its physical batch, so it is an upper bound, not a guarantee. 

86 """ 

87 return self._config.embed_batch_sequences * self._config.chunk_size * CHARS_PER_TOKEN 

88 

89 @property 

90 def truncated_total(self) -> int: 

91 """Cumulative count of chunks truncated since process start (thread-safe read).""" 

92 with self._truncated_lock: 

93 return self._truncated_total 

94 

95 def truncate(self, text: str, reserved: int = 0) -> str: 

96 """Truncate text to the embed char budget, counting any truncation. 

97 

98 *reserved* is charged against the budget so an instruction prefix 

99 prepended afterwards still leaves the sent text inside the guard. 

100 """ 

101 budget = max(1, self.embed_char_budget - reserved) 

102 if len(text) <= budget: 

103 return text 

104 log.debug("Truncating chunk from %d to %d chars for embedding", len(text), budget) 

105 with self._truncated_lock: 

106 self._truncated_total += 1 

107 return text[:budget] 

108 

109 def validate_vector(self, vector: Vector) -> None: 

110 """Validate embedding vector dimension and values.""" 

111 if len(vector) != self._config.embedding_dim: 

112 raise ValueError( 

113 f"Embedding dimension mismatch: expected {self._config.embedding_dim}, " 

114 f"got {len(vector)}" 

115 ) 

116 arr = np.asarray(vector, dtype=np.float64) 

117 bad = np.where(~np.isfinite(arr))[0] 

118 if bad.size: 

119 i = int(bad[0]) 

120 raise ValueError(f"Embedding contains invalid value at index {i}: {vector[i]}") 

121 

122 def validate_model(self) -> bool: 

123 """Availability gate for the startup and ingest paths: warns when it fails. 

124 

125 Same probe as :meth:`embedding_available`, but this is the entry-point 

126 check whose whole job is to not fail silently -- a missing model here 

127 means every chunk of the run ahead will fail to embed, and the operator 

128 needs to hear that once, up front, rather than as a per-file error much 

129 later. Callers that can genuinely degrade (search falling back to 

130 keyword) ask :meth:`embedding_available` and stay quiet. 

131 """ 

132 if self.embedding_available(): 

133 return True 

134 if not self._config.embedding_model: 

135 # Unconfigured is a state, not a failure: nothing was chosen, so 

136 # there is nothing to warn about. One INFO line for the curious. 

137 log.info("No embedding model configured; embedding is off until one is set.") 

138 return False 

139 log.warning( 

140 "Embedding model %r is not available; embedding will fail. " 

141 "Pull it or set a different embedding_model.", 

142 self._config.embedding_model, 

143 ) 

144 return False 

145 

146 def embedding_available(self) -> bool: 

147 """Return True if the embedding model can be resolved. 

148 

149 Checks the provider model list and the native registry path 

150 resolution. Returns True if either finds the model. 

151 """ 

152 return is_model_available(self._config.embedding_model, self._provider) 

153 

154 def _profile(self) -> EmbeddingProfile: 

155 """Instruction profile for the configured embedder (symmetric if unrecognized).""" 

156 return resolve_embedding_profile(self._config.embedding_model) 

157 

158 def embed(self, text: str) -> Vector: 

159 """Embed a single document string, return vector.""" 

160 return self._embed_with([text], self._profile().doc_prefix)[0] 

161 

162 def embed_query(self, text: str) -> Vector: 

163 """Embed a single query string, applying the model's query instruction if any.""" 

164 return self._embed_with([text], self._profile().query_instruction)[0] 

165 

166 def embed_batch( 

167 self, 

168 texts: list[str], 

169 *, 

170 source: str = "", 

171 on_progress: DetailedProgressCallback = noop_callback, 

172 ) -> list[Vector]: 

173 """Embed document texts with adaptive batching, return list of vectors.""" 

174 return self._embed_with( 

175 texts, self._profile().doc_prefix, source=source, on_progress=on_progress 

176 ) 

177 

178 def embed_query_batch( 

179 self, 

180 texts: list[str], 

181 *, 

182 source: str = "", 

183 on_progress: DetailedProgressCallback = noop_callback, 

184 ) -> list[Vector]: 

185 """Embed query texts (query instruction applied), adaptive batching.""" 

186 return self._embed_with( 

187 texts, self._profile().query_instruction, source=source, on_progress=on_progress 

188 ) 

189 

190 def _embed_with( 

191 self, 

192 texts: list[str], 

193 prefix: str, 

194 *, 

195 source: str = "", 

196 on_progress: DetailedProgressCallback = noop_callback, 

197 ) -> list[Vector]: 

198 """Adaptive-batched embed of *texts*, each prefixed (query/document instruction). 

199 

200 Fires ``embed`` progress events per batch when *on_progress* is provided. 

201 """ 

202 if not texts: 

203 return [] 

204 total_chunks = len(texts) 

205 max_batch_chars = self.batch_char_budget 

206 vectors: list[Vector] = [] 

207 batch: list[str] = [] 

208 batch_chars = 0 

209 for text in texts: 

210 # The prefix is charged against the budget: it is part of what the 

211 # embed model receives, so counting only the text would ship 

212 # budget+len(prefix) chars and lose the tail the clamp protects. 

213 truncated = prefix + self.truncate(text, reserved=len(prefix)) 

214 chunk_len = len(truncated) 

215 if batch and batch_chars + chunk_len > max_batch_chars: 

216 vectors.extend(self._provider.embed(batch)) 

217 on_progress( 

218 EventType.EMBED, 

219 EmbedEvent(file=source, chunk=len(vectors), total_chunks=total_chunks), 

220 ) 

221 batch = [] 

222 batch_chars = 0 

223 batch.append(truncated) 

224 batch_chars += chunk_len 

225 if batch: 

226 vectors.extend(self._provider.embed(batch)) 

227 on_progress( 

228 EventType.EMBED, 

229 EmbedEvent(file=source, chunk=len(vectors), total_chunks=total_chunks), 

230 ) 

231 for vec in vectors: 

232 self.validate_vector(vec) 

233 return vectors