Multi-stage evidence-grounded inference architecture replacing the monolithic 9B model extraction pipeline. CPU-first specialist services handle routine extraction while the 9B vLLM model is preserved for semantic adjudication of ambiguous cases. Key components: - Capability-aware inference gateway (OpenAI-compatible + Ollama) - Endpoint registry with DB migrations and REST API - Sentence-aware document segmenter (property tests) - Deterministic financial parsing with offset integrity - Symbol resolution with ambiguity detection - Specialist service (GLiNER2, dynamic batching, K8s deployment) - Company-specific sentiment (FinBERT, calibration) - Retrieval-based novelty and duplicate detection - Confidence calibration pipeline - Deterministic routing engine (property tests) - 9B adjudication layer with VRAM gating - Stock-specific impact model (features, labels, baseline, trained) - Pipeline orchestrator (state machine, queues, leases, feature flags) - Bounded parallelism (async workers, semaphore, load shedding) - Observability (tracing, metrics, alerts) - Compatibility adapter (v3→v2 golden mapping tests) - Shadow/canary promotion framework - Active learning and fine-tuning pipeline Test results: 1,161 tests pass, ruff lint clean. All 282 spec tasks completed.
166 lines
5.3 KiB
Python
166 lines
5.3 KiB
Python
"""Replaceable compact embedding backend for novelty retrieval.
|
|
|
|
Provides:
|
|
- EmbeddingBackend protocol for pluggable embedding models
|
|
- MockEmbeddingBackend for deterministic testing
|
|
- SentenceTransformerBackend stub for production (all-MiniLM-L6-v2)
|
|
- cosine_similarity utility function
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import math
|
|
import struct
|
|
from typing import Protocol, runtime_checkable
|
|
|
|
|
|
@runtime_checkable
|
|
class EmbeddingBackend(Protocol):
|
|
"""Protocol for embedding backends.
|
|
|
|
Implementations must produce fixed-dimension vectors for a batch of texts.
|
|
The backend is designed to be replaceable: swap between mock, local model,
|
|
and remote API backends without changing scoring logic.
|
|
"""
|
|
|
|
@property
|
|
def dimension(self) -> int:
|
|
"""Return the embedding dimension produced by this backend."""
|
|
...
|
|
|
|
def embed(self, texts: list[str]) -> list[list[float]]:
|
|
"""Embed a batch of texts into dense vectors.
|
|
|
|
Args:
|
|
texts: List of text strings to embed.
|
|
|
|
Returns:
|
|
List of embedding vectors, one per input text.
|
|
Each vector has length == self.dimension.
|
|
"""
|
|
...
|
|
|
|
|
|
class MockEmbeddingBackend:
|
|
"""Deterministic hash-based embedding backend for testing.
|
|
|
|
Produces consistent embeddings using text hashing. Useful for
|
|
unit tests and integration tests that need repeatable results
|
|
without loading a real model.
|
|
"""
|
|
|
|
def __init__(self, dimension: int = 384) -> None:
|
|
self._dimension = dimension
|
|
|
|
@property
|
|
def dimension(self) -> int:
|
|
return self._dimension
|
|
|
|
def embed(self, texts: list[str]) -> list[list[float]]:
|
|
"""Generate deterministic embeddings from text hashes.
|
|
|
|
Uses SHA-256 expanded to fill the dimension. Normalizes to unit length
|
|
for compatibility with cosine similarity.
|
|
"""
|
|
results = []
|
|
for text in texts:
|
|
raw = self._hash_to_vector(text)
|
|
norm = math.sqrt(sum(x * x for x in raw))
|
|
if norm > 0:
|
|
normalized = [x / norm for x in raw]
|
|
else:
|
|
normalized = raw
|
|
results.append(normalized)
|
|
return results
|
|
|
|
def _hash_to_vector(self, text: str) -> list[float]:
|
|
"""Expand text hash into a vector of the target dimension."""
|
|
vector = []
|
|
# Generate enough hash bytes to fill dimension
|
|
chunk_idx = 0
|
|
while len(vector) < self._dimension:
|
|
data = f"{text}:{chunk_idx}".encode("utf-8")
|
|
digest = hashlib.sha256(data).digest()
|
|
# Convert 32 bytes to 8 floats (4 bytes each)
|
|
for i in range(0, 32, 4):
|
|
if len(vector) >= self._dimension:
|
|
break
|
|
# Unpack as float in [-1, 1] range
|
|
raw_int = struct.unpack("<I", digest[i : i + 4])[0]
|
|
value = (raw_int / (2**32 - 1)) * 2.0 - 1.0
|
|
vector.append(value)
|
|
chunk_idx += 1
|
|
return vector[: self._dimension]
|
|
|
|
|
|
class SentenceTransformerBackend:
|
|
"""Production embedding backend using sentence-transformers.
|
|
|
|
Wraps all-MiniLM-L6-v2 (384-dimensional) for compact, fast embeddings.
|
|
The model loads lazily on first call to avoid startup cost when not needed.
|
|
|
|
Note: Requires `sentence-transformers` package to be installed.
|
|
This is a stub—actual model loading is deferred to production deployment.
|
|
"""
|
|
|
|
MODEL_NAME = "all-MiniLM-L6-v2"
|
|
|
|
def __init__(self) -> None:
|
|
self._model = None
|
|
self._dimension = 384
|
|
|
|
@property
|
|
def dimension(self) -> int:
|
|
return self._dimension
|
|
|
|
def embed(self, texts: list[str]) -> list[list[float]]:
|
|
"""Embed texts using the sentence-transformers model.
|
|
|
|
Lazily loads the model on first invocation.
|
|
|
|
Raises:
|
|
ImportError: If sentence-transformers is not installed.
|
|
"""
|
|
if self._model is None:
|
|
self._load_model()
|
|
embeddings = self._model.encode(texts, normalize_embeddings=True)
|
|
return [emb.tolist() for emb in embeddings]
|
|
|
|
def _load_model(self) -> None:
|
|
"""Load the sentence-transformers model."""
|
|
try:
|
|
from sentence_transformers import SentenceTransformer
|
|
except ImportError as e:
|
|
raise ImportError(
|
|
"sentence-transformers package is required for SentenceTransformerBackend. "
|
|
"Install with: pip install sentence-transformers"
|
|
) from e
|
|
self._model = SentenceTransformer(self.MODEL_NAME)
|
|
|
|
|
|
def cosine_similarity(a: list[float], b: list[float]) -> float:
|
|
"""Compute cosine similarity between two embedding vectors.
|
|
|
|
Args:
|
|
a: First embedding vector.
|
|
b: Second embedding vector (must be same dimension as a).
|
|
|
|
Returns:
|
|
Cosine similarity in range [-1, 1]. Returns 0.0 for zero vectors.
|
|
|
|
Raises:
|
|
ValueError: If vectors have different dimensions.
|
|
"""
|
|
if len(a) != len(b):
|
|
raise ValueError(f"Vectors must have same dimension: {len(a)} != {len(b)}")
|
|
|
|
dot = sum(x * y for x, y in zip(a, b))
|
|
norm_a = math.sqrt(sum(x * x for x in a))
|
|
norm_b = math.sqrt(sum(x * x for x in b))
|
|
|
|
if norm_a == 0.0 or norm_b == 0.0:
|
|
return 0.0
|
|
|
|
return dot / (norm_a * norm_b)
|