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.
- getbased_rag-0.6.1.dist-info/METADATA +243 -0
- getbased_rag-0.6.1.dist-info/RECORD +15 -0
- getbased_rag-0.6.1.dist-info/WHEEL +5 -0
- getbased_rag-0.6.1.dist-info/entry_points.txt +2 -0
- getbased_rag-0.6.1.dist-info/licenses/LICENSE +22 -0
- getbased_rag-0.6.1.dist-info/top_level.txt +1 -0
- lens/__init__.py +3 -0
- lens/api_key.py +55 -0
- lens/cli.py +225 -0
- lens/config.py +147 -0
- lens/embedder.py +524 -0
- lens/ingest.py +365 -0
- lens/registry.py +256 -0
- lens/server.py +826 -0
- lens/store.py +283 -0
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
|