Wire the guarded conversational RAG answer layer end-to-end
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
from rag.ports import QueryEmbeddingUnavailable
|
||||
|
||||
BEDROCK_RUNTIME_SERVICE = "bedrock-runtime"
|
||||
COHERE_EMBED_V4 = "cohere.embed-v4:0"
|
||||
|
||||
# Cohere embeds corpus records and queries into different subspaces. Sending
|
||||
# the corpus value here raises no error — recall just drops silently — so this
|
||||
# constant exists to make the asymmetry visible rather than incidental.
|
||||
COHERE_QUERY_INPUT_TYPE = "search_query"
|
||||
|
||||
|
||||
def _provider_error_types() -> tuple[type[BaseException], ...]:
|
||||
"""botocore's error classes, or none when botocore is absent.
|
||||
|
||||
Resolved lazily and tolerantly so a test injecting a stub client — and a
|
||||
deployment that never enables a cloud provider — neither imports botocore
|
||||
nor depends on it being installed.
|
||||
"""
|
||||
try:
|
||||
from botocore.exceptions import BotoCoreError, ClientError
|
||||
except ImportError:
|
||||
return ()
|
||||
return (BotoCoreError, ClientError)
|
||||
|
||||
|
||||
class BedrockCohereQueryEmbedder:
|
||||
"""Embeds a query with the same model the collection was built from.
|
||||
|
||||
A collection loaded with Cohere vectors and queried with any other embedder
|
||||
still returns results and still raises nothing — the hits are simply
|
||||
meaningless. That failure is silent, which is why the corpus manifest
|
||||
records `model_id` and why this adapter names the model explicitly.
|
||||
|
||||
The request shape is duplicated from `ingestion/embed/bedrock_cohere.py`
|
||||
rather than imported: `ingestion` is a separate deployable and importing it
|
||||
here would couple the API to the batch pipeline. The duplication is one
|
||||
JSON body and is deliberate.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
dimensions: int,
|
||||
region: str = "us-east-1",
|
||||
client: Any | None = None,
|
||||
model_id: str = COHERE_EMBED_V4,
|
||||
) -> None:
|
||||
if dimensions <= 0:
|
||||
raise ValueError("dimensions must be positive")
|
||||
self._dimensions = dimensions
|
||||
self._region = region
|
||||
self._client = client
|
||||
self._model_id = model_id
|
||||
|
||||
@property
|
||||
def dimensions(self) -> int:
|
||||
return self._dimensions
|
||||
|
||||
@property
|
||||
def model_id(self) -> str:
|
||||
return self._model_id
|
||||
|
||||
def _runtime(self) -> Any:
|
||||
if self._client is None:
|
||||
import boto3
|
||||
from botocore.config import Config
|
||||
|
||||
self._client = boto3.client(
|
||||
BEDROCK_RUNTIME_SERVICE,
|
||||
region_name=self._region,
|
||||
config=Config(
|
||||
connect_timeout=10,
|
||||
read_timeout=30,
|
||||
retries={"max_attempts": 3, "mode": "standard"},
|
||||
),
|
||||
)
|
||||
return self._client
|
||||
|
||||
def embed_query(self, text: str) -> list[float]:
|
||||
import json
|
||||
|
||||
try:
|
||||
response = self._runtime().invoke_model(
|
||||
modelId=self._model_id,
|
||||
body=json.dumps(
|
||||
{
|
||||
"texts": [text],
|
||||
"input_type": COHERE_QUERY_INPUT_TYPE,
|
||||
"embedding_types": ["float"],
|
||||
"output_dimension": self._dimensions,
|
||||
}
|
||||
),
|
||||
accept="*/*",
|
||||
contentType="application/json",
|
||||
)
|
||||
except _provider_error_types() as error:
|
||||
raise QueryEmbeddingUnavailable(
|
||||
f"{self._model_id} could not be invoked: {type(error).__name__}"
|
||||
) from error
|
||||
body = json.loads(response["body"].read())
|
||||
embeddings = body.get("embeddings")
|
||||
if isinstance(embeddings, dict):
|
||||
rows = embeddings.get("float")
|
||||
else:
|
||||
rows = embeddings
|
||||
if not rows:
|
||||
raise ValueError(
|
||||
f"{self._model_id} returned no float embedding; "
|
||||
f"response keys were {sorted(body)}"
|
||||
)
|
||||
values = list(rows[0])
|
||||
if len(values) != self._dimensions:
|
||||
raise ValueError(
|
||||
f"{self._model_id} returned {len(values)} dimensions, "
|
||||
f"expected {self._dimensions}"
|
||||
)
|
||||
return values
|
||||
|
||||
|
||||
class SectionOnlyQueryEmbedder:
|
||||
"""Refuses to embed, confining retrieval to the route that is verified.
|
||||
|
||||
The section route resolves the question's attribute to a `section_key` and
|
||||
filters on it; no vector is involved, and it measured 16/16 on the
|
||||
human-written golden questions on 2026-08-04. Similarity measured 0.544 and
|
||||
its provider is currently revoked.
|
||||
|
||||
Declining locally and immediately is better than the two alternatives it
|
||||
replaces: a `cohere-v4` round-trip spends the boto3 retry budget before
|
||||
failing, and `LocalHashQueryEmbedder` searches a SHA-256 vector against a
|
||||
Cohere collection, which returns confident and meaningless hits.
|
||||
"""
|
||||
|
||||
def __init__(self, dimensions: int) -> None:
|
||||
if dimensions <= 0:
|
||||
raise ValueError("dimensions must be positive")
|
||||
self._dimensions = dimensions
|
||||
|
||||
@property
|
||||
def dimensions(self) -> int:
|
||||
return self._dimensions
|
||||
|
||||
def embed_query(self, text: str) -> list[float]: # noqa: ARG002
|
||||
# The text is irrelevant: this embedder exists to refuse, not to embed.
|
||||
raise QueryEmbeddingUnavailable(
|
||||
"no query embedding provider is enabled; set EMBEDDING_PROVIDER to "
|
||||
"use the similarity fallback"
|
||||
)
|
||||
|
||||
|
||||
class LocalHashQueryEmbedder:
|
||||
"""Deterministic local plumbing probe; not a semantic retrieval model."""
|
||||
|
||||
def __init__(self, dimensions: int) -> None:
|
||||
if dimensions <= 0:
|
||||
raise ValueError("dimensions must be positive")
|
||||
self._dimensions = dimensions
|
||||
|
||||
@property
|
||||
def dimensions(self) -> int:
|
||||
return self._dimensions
|
||||
|
||||
def embed_query(self, text: str) -> list[float]:
|
||||
vector = [0.0] * self._dimensions
|
||||
for token in text.casefold().split():
|
||||
digest = hashlib.sha256(token.encode("utf-8")).digest()
|
||||
index = int.from_bytes(digest[:4], "big") % self._dimensions
|
||||
sign = 1.0 if digest[4] & 1 else -1.0
|
||||
vector[index] += sign
|
||||
norm = math.sqrt(sum(value * value for value in vector))
|
||||
if norm == 0:
|
||||
return vector
|
||||
return [value / norm for value in vector]
|
||||
Reference in New Issue
Block a user