rmi-backend/app/rag/embedder.py
opencode c762564d40 style(rmi-backend): complete lint cleanup — 1175→0 ruff errors
- Fix 71 invalid-syntax files (class-body newline-broken assignments)
- Add from/None chain to 307 B904 raise-without-from sites
- Add B008 ignore to ruff.toml (already in pyproject.toml)
- Noqa F401 on __init__.py re-exports (137 sites)
- Noqa E402 on deferred imports (63 sites)
- Bulk-add stdlib/FastAPI/project imports for F821 (127 sites)
- Replace ×→x, –→-, …→... in docstrings (4093 chars)
- Manual refactor of 5 SIM103/SIM116 patterns

Tests: 791 passed (66 deselected due to pre-existing Redis issues in test_rag.py)
Co-authored-by: opencode <opencode@rugmunch.io>
2026-07-06 15:43:20 +02:00

153 lines
5.3 KiB
Python

"""RAG Dual-Dim Embedder (M3 in v3 unfuck plan).
Wraps the FROZEN app/crypto_embeddings.py to support both bge-m3 (1024d, current)
and qwen3-embedding:4b (2048d, target) during zero-downtime migration.
Strategy: reindex one collection per night, smallest first, verify each morning.
If eval harness reports <8% nDCG@10 improvement, defer the rest.
See DESIGN.md §M3 for the full math and migration plan.
"""
from __future__ import annotations
from enum import Enum
from typing import Any
class EmbedderBackend(Enum):
"""Available embedding backends."""
BGE_M3 = "bge-m3" # 1024d, legacy
QWEN3_4B = "qwen3-embedding:4b" # 2048d, target
# ── Per-backend configuration ──────────────────────────────────────────
_BACKEND_DIMS: dict[EmbedderBackend, int] = {
EmbedderBackend.BGE_M3: 1024,
EmbedderBackend.QWEN3_4B: 2048,
}
def backend_dim(backend: EmbedderBackend) -> int:
"""Return the embedding dimension for a backend."""
return _BACKEND_DIMS[backend]
# ── Dual-dim wrapper ────────────────────────────────────────────────────
class DualDimEmbedder:
"""Wraps Ollama embedding for any backend. Same interface regardless of dim."""
def __init__(
self,
backend: EmbedderBackend,
ollama_url: str = "http://ollama:11434",
) -> None:
self.backend = backend
self.ollama_url = ollama_url.rstrip("/")
self.dim = backend_dim(backend)
async def embed(self, texts: list[str]) -> list[list[float]]:
"""Embed a batch of texts. Returns one vector per text.
Uses the Ollama /api/embeddings endpoint with the configured model.
"""
import httpx
if not texts:
return []
async with httpx.AsyncClient(timeout=60.0) as client:
resp = await client.post(
f"{self.ollama_url}/api/embeddings",
json={"model": self.backend.value, "prompt": texts},
)
resp.raise_for_status()
data = resp.json()
# Ollama returns {"embedding": [[...], [...]]} for single, or {"embeddings": [[...]]}
if "embeddings" in data: # noqa: SIM108
vectors = data["embeddings"]
else:
# Single-text fallback - Ollama returns {"embedding": [...]}
vectors = [data["embedding"]] if "embedding" in data else []
# Validate dimensions to catch backend mismatches early.
for i, vec in enumerate(vectors):
if len(vec) != self.dim:
raise RuntimeError(
f"Embedder {self.backend.value} returned {len(vec)}d vector "
f"for text[{i}], expected {self.dim}d. "
f"Check that ollama has the right model pulled."
)
return vectors
async def embed_one(self, text: str) -> list[float]:
"""Convenience: embed a single text."""
result = await self.embed([text])
return result[0] if result else [0.0] * self.dim
# ── Migration helpers ──────────────────────────────────────────────────
async def reindex_collection(
name: str,
source: DualDimEmbedder,
target: DualDimEmbedder,
fetch_docs: Any,
write_collection: Any,
verify_queries: list[str] | None = None,
) -> bool:
"""Re-embed one collection from source backend to target backend.
Args:
name: collection name
source: existing backend (e.g. bge-m3)
target: new backend (e.g. qwen3-embedding:4b)
fetch_docs: async () -> list[(id, text)] callable
write_collection: async (name, vectors) -> None callable
verify_queries: optional list of known queries to verify retrieval
Returns True if migration succeeded (or verification skipped).
"""
if source.dim == target.dim:
# Same dim - no migration needed.
return True
docs = await fetch_docs()
if not docs:
return True
texts = [text for _, text in docs]
new_vectors = await target.embed(texts)
new_name = f"{name}_v2"
await write_collection(new_name, list(zip((id_ for id_, _ in docs), new_vectors, strict=False)))
# Atomic swap - implementation-specific. Caller handles.
# For FAISS: rename .index files. For Qdrant: rename collections.
if verify_queries:
# Verify retrieval on known queries before swapping production traffic.
# Implementation-specific; placeholder for now.
pass
return True
# ── CLI ────────────────────────────────────────────────────────────────
def _main() -> None:
"""CLI: list available backends and their dims."""
import argparse
parser = argparse.ArgumentParser(description="RAG dual-dim embedder")
parser.add_argument(
"--list-backends", action="store_true", help="List available backends"
)
args = parser.parse_args()
if args.list_backends:
for backend in EmbedderBackend:
print(f"{backend.value} {_BACKEND_DIMS[backend]}d")
if __name__ == "__main__":
_main()