getbased-rag 0.6.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
lens/config.py ADDED
@@ -0,0 +1,147 @@
1
+ """Configuration for getbased Lens."""
2
+
3
+ import os
4
+ import sys
5
+ from pathlib import Path
6
+ from dataclasses import dataclass, field
7
+
8
+
9
+ def _default_data_dir() -> Path:
10
+ """Default data directory — platform-specific, matches what Tauri's
11
+ dirs::data_dir() crate returns so the CLI and the desktop app share one
12
+ location. A user running `lens serve` from their shell sees the same DB
13
+ that the Tauri app writes to.
14
+
15
+ Linux: $XDG_DATA_HOME/getbased/lens or ~/.local/share/getbased/lens
16
+ macOS: ~/Library/Application Support/getbased/lens
17
+ Windows: %APPDATA%\\getbased\\lens
18
+
19
+ If the legacy ~/.getbased/lens/ exists (pre-v1.21 installs) and the new
20
+ path does not, we honor the legacy path to avoid orphaning user data.
21
+ """
22
+ home = Path.home()
23
+ legacy = home / ".getbased" / "lens"
24
+
25
+ if sys.platform == "darwin":
26
+ new_default = home / "Library" / "Application Support" / "getbased" / "lens"
27
+ elif sys.platform.startswith("win"):
28
+ appdata = os.environ.get("APPDATA")
29
+ new_default = (Path(appdata) if appdata else home / "AppData" / "Roaming") / "getbased" / "lens"
30
+ else:
31
+ xdg = os.environ.get("XDG_DATA_HOME")
32
+ new_default = (Path(xdg) if xdg else home / ".local" / "share") / "getbased" / "lens"
33
+
34
+ if not new_default.exists() and legacy.exists():
35
+ return legacy
36
+ return new_default
37
+
38
+
39
+ @dataclass
40
+ class LensConfig:
41
+ """All Lens configuration, loaded from environment variables."""
42
+
43
+ # Server. Default to loopback only — exposing RAG to LAN by default would
44
+ # leak knowledge-base queries to anyone on the local network. Override via
45
+ # LENS_HOST=0.0.0.0 if you explicitly want LAN access.
46
+ host: str = "127.0.0.1"
47
+ port: int = 8322
48
+
49
+ # Data
50
+ data_dir: Path = field(default_factory=_default_data_dir)
51
+ api_key_file: Path = field(default_factory=lambda: _default_data_dir() / "api_key")
52
+
53
+ # Collection
54
+ collection: str = "knowledge"
55
+
56
+ # Embeddings. Fully-qualified HF repo id so sentence-transformers always
57
+ # resolves to the same model regardless of where the lens runs (local
58
+ # shell vs Tauri-spawned). The bare "all-MiniLM-L6-v2" short name is
59
+ # also accepted by ST but routes through a different lookup path.
60
+ embedding_model: str = "sentence-transformers/all-MiniLM-L6-v2" # ~90MB, 384d, fast on CPU
61
+ similarity_floor: float = 0.55
62
+
63
+ # Qdrant backend
64
+ qdrant_mode: str = "local" # "local" or "cloud"
65
+ qdrant_cloud_url: str = ""
66
+ qdrant_cloud_key: str = ""
67
+
68
+ # Cloud inference (Qdrant Cloud embeds for you — no local model needed)
69
+ cloud_inference: bool = False
70
+
71
+ # ONNX Runtime acceleration (set by Tauri wrapper)
72
+ onnx_provider: str = "" # "cuda", "rocm", "openvino", "coreml", "cpu", or "" (auto)
73
+
74
+ # Reranker (optional, heavy on CPU)
75
+ reranker: bool = False
76
+ reranker_candidates: int = 30
77
+
78
+ # Response constraints
79
+ max_chunks: int = 10
80
+ max_chunk_chars: int = 4000
81
+ max_source_chars: int = 200
82
+ max_response_bytes: int = 32768
83
+
84
+ # Chunking
85
+ chunk_max_size: int = 800
86
+ chunk_min_size: int = 50
87
+ chunk_overlap: int = 50
88
+
89
+ @classmethod
90
+ def from_env(cls) -> "LensConfig":
91
+ """Load config from environment variables."""
92
+ data_dir = Path(os.environ.get("LENS_DATA_DIR", str(_default_data_dir())))
93
+
94
+ return cls(
95
+ host=os.environ.get("LENS_HOST", "127.0.0.1"),
96
+ port=int(os.environ.get("LENS_PORT", "8322")),
97
+ data_dir=data_dir,
98
+ api_key_file=Path(os.environ.get("LENS_API_KEY_FILE", str(data_dir / "api_key"))),
99
+ collection=os.environ.get("LENS_COLLECTION", "knowledge"),
100
+ embedding_model=os.environ.get(
101
+ "LENS_EMBEDDING_MODEL", "sentence-transformers/all-MiniLM-L6-v2"
102
+ ),
103
+ similarity_floor=float(os.environ.get("LENS_SIMILARITY_FLOOR", "0.55")),
104
+ qdrant_mode=os.environ.get("LENS_QDRANT_MODE", "local"),
105
+ qdrant_cloud_url=os.environ.get("LENS_QDRANT_CLOUD_URL", ""),
106
+ qdrant_cloud_key=os.environ.get("LENS_QDRANT_CLOUD_KEY", ""),
107
+ cloud_inference=os.environ.get("LENS_CLOUD_INFERENCE", "").lower() in ("1", "true", "yes"),
108
+ onnx_provider=os.environ.get("LENS_ONNX_PROVIDER", ""),
109
+ reranker=os.environ.get("LENS_RERANKER", "").lower() in ("1", "true", "yes"),
110
+ reranker_candidates=int(os.environ.get("LENS_RERANKER_CANDIDATES", "30")),
111
+ chunk_max_size=int(os.environ.get("LENS_CHUNK_MAX_SIZE", "800")),
112
+ chunk_min_size=int(os.environ.get("LENS_CHUNK_MIN_SIZE", "50")),
113
+ chunk_overlap=int(os.environ.get("LENS_CHUNK_OVERLAP", "50")),
114
+ )
115
+
116
+ @property
117
+ def qdrant_path(self) -> Path:
118
+ """Local Qdrant storage path."""
119
+ return self.data_dir / "qdrant"
120
+
121
+ @property
122
+ def is_cloud(self) -> bool:
123
+ return self.qdrant_mode == "cloud"
124
+
125
+ def ensure_dirs(self):
126
+ """Create data directories if they don't exist."""
127
+ self.data_dir.mkdir(parents=True, exist_ok=True)
128
+ self.api_key_file.parent.mkdir(parents=True, exist_ok=True)
129
+
130
+ def display(self) -> str:
131
+ """Human-readable config display."""
132
+ lines = [
133
+ f" host: {self.host}",
134
+ f" port: {self.port}",
135
+ f" data_dir: {self.data_dir}",
136
+ f" collection: {self.collection}",
137
+ f" embedding_model: {self.embedding_model}",
138
+ f" similarity_floor: {self.similarity_floor}",
139
+ f" qdrant_mode: {self.qdrant_mode}",
140
+ ]
141
+ if self.is_cloud:
142
+ lines.append(f" qdrant_cloud_url: {self.qdrant_cloud_url}")
143
+ lines.append(f" cloud_inference: {self.cloud_inference}")
144
+ else:
145
+ lines.append(f" qdrant_path: {self.qdrant_path}")
146
+ lines.append(f" reranker: {self.reranker}")
147
+ return "getbased Lens Configuration:\n" + "\n".join(lines)
lens/embedder.py ADDED
@@ -0,0 +1,524 @@
1
+ """Embedding backends — ONNX Runtime, sentence-transformers, or Qdrant Cloud Inference.
2
+
3
+ ABC design with lazy-loading, caching, and a factory function.
4
+ The ONNX backend is preferred when available (set via LENS_ONNX_PROVIDER env var)
5
+ because it's lighter than PyTorch and supports GPU acceleration directly.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import os
12
+ import sys
13
+ from abc import ABC, abstractmethod
14
+ from pathlib import Path
15
+
16
+ from .config import LensConfig
17
+
18
+ log = logging.getLogger("lens.embedder")
19
+
20
+
21
+ def _platform_getbased_data_dirs() -> list[Path]:
22
+ """Default getbased data directories per platform — matches what Tauri uses.
23
+ Returns multiple candidates to handle dev (XDG) and bundled (platform-default) layouts.
24
+ """
25
+ home = Path.home()
26
+ paths = []
27
+ if sys.platform == "darwin":
28
+ # Tauri's dirs::data_dir() on macOS = ~/Library/Application Support
29
+ paths.append(home / "Library" / "Application Support" / "getbased" / "lens" / "models")
30
+ elif sys.platform.startswith("win"):
31
+ # Tauri's dirs::data_dir() on Windows = %APPDATA% (Roaming)
32
+ appdata = os.environ.get("APPDATA")
33
+ if appdata:
34
+ paths.append(Path(appdata) / "getbased" / "lens" / "models")
35
+ else:
36
+ # Linux: dirs::data_dir() = $XDG_DATA_HOME or ~/.local/share
37
+ xdg = os.environ.get("XDG_DATA_HOME")
38
+ if xdg:
39
+ paths.append(Path(xdg) / "getbased" / "lens" / "models")
40
+ paths.append(home / ".local" / "share" / "getbased" / "lens" / "models")
41
+ # Always include the legacy ~/.getbased/lens/models for back-compat
42
+ paths.append(home / ".getbased" / "lens" / "models")
43
+ return paths
44
+
45
+ # ── ABC ────────────────────────────────────────────────────────────
46
+
47
+ class Embedder(ABC):
48
+ """Abstract embedding interface."""
49
+
50
+ @abstractmethod
51
+ def encode(self, texts: list[str]) -> list[list[float]]:
52
+ """Encode a batch of texts into normalized vectors."""
53
+ ...
54
+
55
+ @abstractmethod
56
+ def dimension(self) -> int:
57
+ """Return the embedding dimensionality."""
58
+ ...
59
+
60
+ def info(self) -> dict:
61
+ """Return a small dict describing this backend — which engine,
62
+ which model, active provider, etc. Surfaces to the dashboard so
63
+ users can see at a glance what's doing the embedding work.
64
+ Subclasses override; default reports engine name only."""
65
+ return {"engine": self.__class__.__name__, "dimension": self.dimension()}
66
+
67
+
68
+ # ── Known model dimensions ────────────────────────────────────────
69
+
70
+ _MODEL_DIMS: dict[str, int] = {
71
+ "all-MiniLM-L6-v2": 384,
72
+ "all-MiniLM-L12-v2": 384,
73
+ "BAAI/bge-m3": 1024,
74
+ "BAAI/bge-small-en-v1.5": 384,
75
+ "BAAI/bge-base-en-v1.5": 768,
76
+ "BAAI/bge-large-en-v1.5": 1024,
77
+ }
78
+
79
+
80
+ # ── ONNX Runtime (preferred) ─────────────────────────────────────
81
+
82
+ class OnnxEmbedder(Embedder):
83
+ """Embedding via ONNX Runtime — light, fast, GPU-accelerated.
84
+
85
+ Uses optimum (HuggingFace) to load ONNX-exported models with
86
+ provider selection: CUDA, ROCm, OpenVINO, CoreML, or CPU.
87
+
88
+ Provider is set via LENS_ONNX_PROVIDER env var (by Tauri wrapper).
89
+ Falls back to CPU if the requested provider isn't available.
90
+ """
91
+
92
+ # Map our provider names to onnxruntime provider strings
93
+ _PROVIDER_MAP: dict[str, list[str]] = {
94
+ "cuda": ["CUDAExecutionProvider", "CPUExecutionProvider"],
95
+ "rocm": ["ROCmExecutionProvider", "CPUExecutionProvider"],
96
+ "openvino": ["OpenVINOExecutionProvider", "CPUExecutionProvider"],
97
+ "coreml": ["CoreMLExecutionProvider", "CPUExecutionProvider"],
98
+ "cpu": ["CPUExecutionProvider"],
99
+ }
100
+
101
+ def __init__(self, model_name: str = "BAAI/bge-m3", provider: str = ""):
102
+ self._model_name = model_name
103
+ self._provider_name = provider
104
+ self._session = None
105
+ self._tokenizer = None
106
+ self._dim: int | None = None
107
+
108
+ # lazy init --------------------------------------------------------
109
+
110
+ def _load(self) -> None:
111
+ if self._session is not None:
112
+ return
113
+
114
+ import onnxruntime as ort
115
+
116
+ # Resolve provider
117
+ providers = self._resolve_providers(ort)
118
+ log.info(
119
+ "Loading ONNX model: %s (providers=%s)",
120
+ self._model_name, providers,
121
+ )
122
+
123
+ # Find or download ONNX model files
124
+ model_dir = self._resolve_model_dir()
125
+
126
+ # Look for ONNX files
127
+ onnx_file = model_dir / "model.onnx"
128
+ if not onnx_file.exists():
129
+ onnx_file = model_dir / "model_optimized.onnx"
130
+ if not onnx_file.exists():
131
+ # Try any .onnx file
132
+ onnx_files = list(model_dir.glob("*.onnx"))
133
+ if onnx_files:
134
+ onnx_file = onnx_files[0]
135
+ else:
136
+ raise FileNotFoundError(
137
+ f"No ONNX model files found in {model_dir}. "
138
+ "Run setup or download model manually."
139
+ )
140
+
141
+ # Create session
142
+ sess_options = ort.SessionOptions()
143
+ sess_options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
144
+
145
+ self._session = ort.InferenceSession(
146
+ str(onnx_file),
147
+ sess_options=sess_options,
148
+ providers=providers,
149
+ )
150
+
151
+ # Log actual provider
152
+ active = self._session.get_providers()
153
+ log.info("ONNX session active providers: %s", active)
154
+
155
+ # Load tokenizer
156
+ self._load_tokenizer(model_dir)
157
+
158
+ # Detect dimension from model output
159
+ self._dim = self._detect_dimension()
160
+ log.info("ONNX model ready (dim=%d, provider=%s)", self._dim, active[0])
161
+
162
+ def _resolve_providers(self, ort) -> list[str]:
163
+ """Resolve the ONNX provider chain from config + available providers."""
164
+ available = ort.get_available_providers()
165
+ log.debug("Available ONNX providers: %s", available)
166
+
167
+ if self._provider_name and self._provider_name in self._PROVIDER_MAP:
168
+ requested = self._PROVIDER_MAP[self._provider_name]
169
+ # Filter to only what's actually available
170
+ resolved = [p for p in requested if p in available]
171
+ if resolved:
172
+ return resolved
173
+ log.warning(
174
+ "Requested provider '%s' not available (have: %s), falling back to CPU",
175
+ self._provider_name, available,
176
+ )
177
+
178
+ # Auto-detect: pick best available
179
+ for provider_key in ("cuda", "rocm", "openvino", "coreml"):
180
+ chain = self._PROVIDER_MAP[provider_key]
181
+ if any(p in available for p in chain):
182
+ return [p for p in chain if p in available]
183
+
184
+ return ["CPUExecutionProvider"]
185
+
186
+ def _resolve_model_dir(self) -> Path:
187
+ """Find the ONNX model directory.
188
+
189
+ Checks (in order):
190
+ 1. LENS_DATA_DIR env var (set by Tauri wrapper) — most reliable
191
+ 2. Platform-correct getbased data dir (Linux/Mac/Windows)
192
+ 3. HuggingFace hub cache (~/.cache/huggingface)
193
+ 4. Optimum auto-download as last resort
194
+ """
195
+ # 1. Tauri wrapper sets LENS_DATA_DIR explicitly — trust it
196
+ env_dir = os.environ.get("LENS_DATA_DIR")
197
+ candidates_to_search = []
198
+ if env_dir:
199
+ candidates_to_search.append(Path(env_dir) / "models")
200
+
201
+ # 2. Fallback to platform-correct default getbased data dir
202
+ candidates_to_search.extend(_platform_getbased_data_dirs())
203
+
204
+ for tauri_models in candidates_to_search:
205
+ if not tauri_models.exists():
206
+ continue
207
+ # huggingface_hub snapshot_download creates models--{slug}/snapshots/{rev}/
208
+ for cached_model in tauri_models.glob("models--*"):
209
+ snapshots = cached_model / "snapshots"
210
+ if not snapshots.exists():
211
+ continue
212
+ snap_dirs = sorted(snapshots.iterdir(), reverse=True)
213
+ for snap in snap_dirs:
214
+ if (snap / "model.onnx").exists() or list(snap.glob("*.onnx")):
215
+ return snap
216
+
217
+ # Check HuggingFace cache
218
+ hf_cache = Path.home() / ".cache" / "huggingface" / "hub"
219
+ if hf_cache.exists():
220
+ # Normalize model name: BAAI/bge-m3 → models--BAAI--bge-m3
221
+ model_slug = self._model_name.replace("/", "--")
222
+ model_hub_dir = hf_cache / f"models--{model_slug}"
223
+ if model_hub_dir.exists():
224
+ snapshots = model_hub_dir / "snapshots"
225
+ if snapshots.exists():
226
+ snap_dirs = sorted(snapshots.iterdir(), reverse=True)
227
+ for snap in snap_dirs:
228
+ if list(snap.glob("*.onnx")):
229
+ return snap
230
+
231
+ # Fallback: try loading via optimum which handles download
232
+ return self._download_via_optimum()
233
+
234
+ def _download_via_optimum(self) -> Path:
235
+ """Download model via optimum if not found locally."""
236
+ try:
237
+ from optimum.onnxruntime import ORTModelForFeatureExtraction
238
+ from transformers import AutoTokenizer
239
+
240
+ log.info("Downloading ONNX model via optimum: %s", self._model_name)
241
+ model = ORTModelForFeatureExtraction.from_pretrained(
242
+ self._model_name, export=True
243
+ )
244
+ tokenizer = AutoTokenizer.from_pretrained(self._model_name)
245
+
246
+ # Save to a local cache
247
+ cache_dir = (
248
+ Path.home() / ".cache" / "getbased" / "onnx_models" / self._model_name.replace("/", "--")
249
+ )
250
+ cache_dir.mkdir(parents=True, exist_ok=True)
251
+ model.save_pretrained(cache_dir)
252
+ tokenizer.save_pretrained(cache_dir)
253
+ log.info("ONNX model cached to %s", cache_dir)
254
+ return cache_dir
255
+ except ImportError:
256
+ raise ImportError(
257
+ "optimum not installed. Install with: pip install optimum[onnxruntime]"
258
+ )
259
+
260
+ def _load_tokenizer(self, model_dir: Path) -> None:
261
+ """Load the tokenizer for the model."""
262
+ from transformers import AutoTokenizer
263
+ self._tokenizer = AutoTokenizer.from_pretrained(str(model_dir))
264
+
265
+ def _detect_dimension(self) -> int:
266
+ """Detect embedding dimension from model output shape."""
267
+ # Try known dimensions first
268
+ if self._model_name in _MODEL_DIMS:
269
+ return _MODEL_DIMS[self._model_name]
270
+
271
+ # Probe with a dummy input
272
+ import numpy as np
273
+
274
+ encoded = self._tokenizer("test", padding=True, truncation=True, return_tensors="np")
275
+ outputs = self._session.run(None, dict(encoded))
276
+ # Last hidden state shape: [batch, seq_len, dim]
277
+ return outputs[0].shape[-1]
278
+
279
+ # public API -------------------------------------------------------
280
+
281
+ def encode(self, texts: list[str]) -> list[list[float]]:
282
+ self._load()
283
+ import numpy as np
284
+
285
+ # Per-model max_length: BGE-M3 supports 8192; most others top at 512.
286
+ # Truncating BGE-M3 at 512 throws away its main long-context advantage.
287
+ max_len = 8192 if "bge-m3" in self._model_name.lower() else 512
288
+
289
+ encoded = self._tokenizer(
290
+ texts,
291
+ padding=True,
292
+ truncation=True,
293
+ max_length=max_len,
294
+ return_tensors="np",
295
+ )
296
+
297
+ outputs = self._session.run(None, dict(encoded))
298
+ # outputs[0] = last_hidden_state: [batch, seq_len, dim]
299
+ embeddings = outputs[0]
300
+
301
+ # Pooling: BGE-M3 + most modern embedding models use mean pooling.
302
+ # CLS pooling is correct for original BERT but produces wrong vectors here.
303
+ if len(embeddings.shape) == 3:
304
+ mask = encoded.get("attention_mask")
305
+ if mask is not None:
306
+ # Masked mean: only average non-padding tokens
307
+ mask_expanded = np.expand_dims(mask, -1).astype(embeddings.dtype)
308
+ summed = (embeddings * mask_expanded).sum(axis=1)
309
+ counts = np.clip(mask_expanded.sum(axis=1), a_min=1e-9, a_max=None)
310
+ embeddings = summed / counts
311
+ else:
312
+ embeddings = embeddings.mean(axis=1)
313
+
314
+ # L2 normalize for cosine similarity
315
+ norms = np.linalg.norm(embeddings, axis=1, keepdims=True)
316
+ norms = np.where(norms == 0, 1, norms)
317
+ embeddings = embeddings / norms
318
+
319
+ return embeddings.tolist()
320
+
321
+ def dimension(self) -> int:
322
+ if self._dim is not None:
323
+ return self._dim
324
+ if self._model_name in _MODEL_DIMS:
325
+ return _MODEL_DIMS[self._model_name]
326
+ self._load()
327
+ return self._dim # type: ignore[return-value]
328
+
329
+ def info(self) -> dict:
330
+ # Prefer the actually-active provider from the live ORT session
331
+ # if the model is loaded; fall back to the configured name so
332
+ # the UI has something to show even before the first encode().
333
+ active = None
334
+ if self._session is not None:
335
+ try:
336
+ providers = self._session.get_providers()
337
+ if providers:
338
+ active = providers[0]
339
+ except Exception:
340
+ pass
341
+ return {
342
+ "engine": "onnx",
343
+ "model": self._model_name,
344
+ "provider": active or (self._provider_name or "auto"),
345
+ "dimension": self.dimension(),
346
+ "loaded": self._session is not None,
347
+ }
348
+
349
+
350
+ # ── Local (sentence-transformers, fallback) ──────────────────────
351
+
352
+ class LocalEmbedder(Embedder):
353
+ """Local embedding via sentence-transformers.
354
+
355
+ Model is lazy-loaded on first ``encode()`` / ``dimension()`` call
356
+ and cached for the lifetime of the instance.
357
+ """
358
+
359
+ def __init__(self, model_name: str = "all-MiniLM-L6-v2"):
360
+ self._model_name = model_name
361
+ self._model = None
362
+ self._dim: int | None = None
363
+
364
+ # lazy init --------------------------------------------------------
365
+
366
+ def _load(self) -> None:
367
+ if self._model is not None:
368
+ return
369
+ from sentence_transformers import SentenceTransformer
370
+
371
+ log.info("Loading embedding model: %s …", self._model_name)
372
+ self._model = SentenceTransformer(self._model_name)
373
+ self._model.eval()
374
+ self._dim = self._model.get_sentence_embedding_dimension()
375
+ log.info("Model ready (dim=%d)", self._dim)
376
+
377
+ # public API -------------------------------------------------------
378
+
379
+ def encode(self, texts: list[str]) -> list[list[float]]:
380
+ self._load()
381
+ embeddings = self._model.encode(
382
+ texts,
383
+ normalize_embeddings=True,
384
+ show_progress_bar=False,
385
+ )
386
+ return embeddings.tolist()
387
+
388
+ def dimension(self) -> int:
389
+ if self._dim is not None:
390
+ return self._dim
391
+ if self._model_name in _MODEL_DIMS:
392
+ return _MODEL_DIMS[self._model_name]
393
+ self._load()
394
+ return self._dim # type: ignore[return-value]
395
+
396
+ def info(self) -> dict:
397
+ # Detect whether torch thinks it has a GPU — sentence-transformers
398
+ # picks it up automatically, so reporting this matches what's
399
+ # actually running. Best-effort: the torch import is cheap once
400
+ # the model has loaded.
401
+ device = "cpu"
402
+ try:
403
+ import torch # noqa: PLC0415
404
+
405
+ if torch.cuda.is_available():
406
+ device = "cuda"
407
+ elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
408
+ device = "mps"
409
+ except Exception:
410
+ pass
411
+ return {
412
+ "engine": "pytorch",
413
+ "model": self._model_name,
414
+ "device": device,
415
+ "dimension": self.dimension(),
416
+ "loaded": self._model is not None,
417
+ }
418
+
419
+
420
+ # ── Cloud Inference (Qdrant Cloud) ────────────────────────────────
421
+
422
+ class CloudInferenceEmbedder(Embedder):
423
+ """Delegates embedding to Qdrant Cloud's built-in inference API.
424
+
425
+ No local model is loaded — vectors come from the cloud endpoint.
426
+ """
427
+
428
+ def __init__(self, url: str, api_key: str, model_name: str = "all-MiniLM-L6-v2"):
429
+ self._url = url
430
+ self._api_key = api_key
431
+ self._model_name = model_name
432
+ self._client = None
433
+ self._dim: int = _MODEL_DIMS.get(model_name, 384)
434
+
435
+ def _ensure_client(self):
436
+ if self._client is not None:
437
+ return
438
+ from qdrant_client import QdrantClient
439
+
440
+ self._client = QdrantClient(url=self._url, api_key=self._api_key)
441
+ log.info("Cloud inference client ready via %s", self._url)
442
+
443
+ def encode(self, texts: list[str]) -> list[list[float]]:
444
+ self._ensure_client()
445
+ from qdrant_client.models import Document
446
+
447
+ vectors: list[list[float]] = []
448
+ batch_size = 32
449
+ for i in range(0, len(texts), batch_size):
450
+ batch = texts[i : i + batch_size]
451
+ docs = [
452
+ Document(text=t, model=self._model_name) for t in batch
453
+ ]
454
+ result = self._client.infer("", docs)
455
+ vectors.extend([list(v) for v in result])
456
+ return vectors
457
+
458
+ def dimension(self) -> int:
459
+ return self._dim
460
+
461
+ def info(self) -> dict:
462
+ # URL might be sensitive if it encodes the workspace — strip the
463
+ # host-tail so the UI gets a hint without leaking the full
464
+ # endpoint. The api_key is never surfaced.
465
+ from urllib.parse import urlparse
466
+
467
+ host = ""
468
+ try:
469
+ host = urlparse(self._url).hostname or ""
470
+ except Exception:
471
+ pass
472
+ return {
473
+ "engine": "qdrant-cloud",
474
+ "model": self._model_name,
475
+ "host": host,
476
+ "dimension": self.dimension(),
477
+ "loaded": self._client is not None,
478
+ }
479
+
480
+
481
+ # ── Factory ────────────────────────────────────────────────────────
482
+
483
+ def create_embedder(config: LensConfig) -> Embedder:
484
+ """Create the appropriate embedder from a LensConfig.
485
+
486
+ Priority:
487
+ 1. Cloud inference (if enabled) — no local model needed
488
+ 2. ONNX Runtime (if onnx_provider set or optimum available) — GPU-accelerated
489
+ 3. sentence-transformers (fallback) — always works
490
+ """
491
+ if config.cloud_inference:
492
+ if not config.qdrant_cloud_url:
493
+ raise ValueError(
494
+ "LENS_QDRANT_CLOUD_URL required when cloud_inference=True"
495
+ )
496
+ return CloudInferenceEmbedder(
497
+ url=config.qdrant_cloud_url,
498
+ api_key=config.qdrant_cloud_key,
499
+ model_name=config.embedding_model,
500
+ )
501
+
502
+ # Try ONNX backend if provider is set or optimum is available
503
+ if config.onnx_provider or _onnx_available():
504
+ log.info(
505
+ "Using ONNX backend (provider=%s)",
506
+ config.onnx_provider or "auto",
507
+ )
508
+ return OnnxEmbedder(
509
+ model_name=config.embedding_model,
510
+ provider=config.onnx_provider,
511
+ )
512
+
513
+ # Fallback to sentence-transformers
514
+ log.info("ONNX not available, falling back to sentence-transformers")
515
+ return LocalEmbedder(model_name=config.embedding_model)
516
+
517
+
518
+ def _onnx_available() -> bool:
519
+ """Check if onnxruntime is importable."""
520
+ try:
521
+ import onnxruntime # noqa: F401
522
+ return True
523
+ except ImportError:
524
+ return False