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

113 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-09-17 10:02 +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 

9from lilbee.core.vectors import Vector 

10from lilbee.data.extract.chunk import CHARS_PER_TOKEN 

11from lilbee.providers.base import LLMProvider 

12from lilbee.providers.model_ref import ProviderModelRef, parse_model_ref 

13from lilbee.retrieval.embedding_profiles import EmbeddingProfile, resolve_embedding_profile 

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

15 

16log = logging.getLogger(__name__) 

17 

18 

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

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

21 

22 

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

24 try: 

25 available = provider.list_models() 

26 except Exception: 

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

28 return False 

29 base = _name_base(ref) 

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

31 

32 

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

34 """True if *model* resolves to a file on this machine. 

35 

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

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

38 """ 

39 from lilbee.providers.engine_params import resolve_model_path 

40 

41 try: 

42 resolve_model_path(model) 

43 except Exception: 

44 return False 

45 return True 

46 

47 

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

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

50 

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

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

53 """ 

54 if not model: 

55 return False 

56 ref = parse_model_ref(model) 

57 if _remote_sees_model(ref, provider): 

58 return True 

59 if ref.is_remote: 

60 return False 

61 return is_model_installed(model) 

62 

63 

64class Embedder: 

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

66 

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

68 self._config = config 

69 self._provider = provider 

70 self._truncated_total = 0 

71 self._truncated_lock = threading.Lock() 

72 

73 @property 

74 def embed_char_budget(self) -> int: 

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

76 

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

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

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

80 """ 

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

82 

83 @property 

84 def batch_char_budget(self) -> int: 

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

86 

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

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

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

90 """ 

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

92 

93 @property 

94 def truncated_total(self) -> int: 

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

96 with self._truncated_lock: 

97 return self._truncated_total 

98 

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

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

101 

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

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

104 """ 

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

106 if len(text) <= budget: 

107 return text 

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

109 with self._truncated_lock: 

110 self._truncated_total += 1 

111 return text[:budget] 

112 

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

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

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

116 raise ValueError( 

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

118 f"got {len(vector)}" 

119 ) 

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

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

122 if bad.size: 

123 i = int(bad[0]) 

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

125 

126 def validate_model(self) -> bool: 

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

128 

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

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

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

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

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

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

135 """ 

136 if self.embedding_available(): 

137 return True 

138 if not self._config.embedding_model: 

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

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

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

142 return False 

143 log.warning( 

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

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

146 self._config.embedding_model, 

147 ) 

148 return False 

149 

150 def embedding_available(self) -> bool: 

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

152 

153 Checks the provider model list and the native registry path 

154 resolution. Returns True if either finds the model. 

155 """ 

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

157 

158 def _profile(self) -> EmbeddingProfile: 

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

160 return resolve_embedding_profile(self._config.embedding_model) 

161 

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

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

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

165 

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

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

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

169 

170 def embed_batch( 

171 self, 

172 texts: list[str], 

173 *, 

174 source: str = "", 

175 on_progress: DetailedProgressCallback = noop_callback, 

176 ) -> list[Vector]: 

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

178 return self._embed_with( 

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

180 ) 

181 

182 def embed_query_batch( 

183 self, 

184 texts: list[str], 

185 *, 

186 source: str = "", 

187 on_progress: DetailedProgressCallback = noop_callback, 

188 ) -> list[Vector]: 

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

190 return self._embed_with( 

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

192 ) 

193 

194 def _embed_with( 

195 self, 

196 texts: list[str], 

197 prefix: str, 

198 *, 

199 source: str = "", 

200 on_progress: DetailedProgressCallback = noop_callback, 

201 ) -> list[Vector]: 

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

203 

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

205 """ 

206 if not texts: 

207 return [] 

208 total_chunks = len(texts) 

209 max_batch_chars = self.batch_char_budget 

210 vectors: list[Vector] = [] 

211 batch: list[str] = [] 

212 batch_chars = 0 

213 for text in texts: 

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

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

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

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

218 chunk_len = len(truncated) 

219 if batch and batch_chars + chunk_len > max_batch_chars: 

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

221 on_progress( 

222 EventType.EMBED, 

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

224 ) 

225 batch = [] 

226 batch_chars = 0 

227 batch.append(truncated) 

228 batch_chars += chunk_len 

229 if batch: 

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

231 on_progress( 

232 EventType.EMBED, 

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

234 ) 

235 for vec in vectors: 

236 self.validate_vector(vec) 

237 return vectors