UBCHelper / src /embed.py
Devdan Schretlen
Claude Opus 5 (1M context)
Render the answer as Markdown, bound API timeouts, fix a false notice
9db5d1e
Raw History Blame Contribute Delete
2.47 kB
"""
Embeddings β€” the single source of truth for turning text into vectors.
Both ingest.py (indexing documents) and retrieve.py (embedding the user's query) call
`embed_texts` here. Routing every embedding through one function guarantees the golden
rule of dense retrieval is never broken: the SAME model embeds the documents and the
queries, so their vectors live in the same space and cosine similarity is meaningful.
We talk to OpenAI's hosted `/v1/embeddings` endpoint (config.EMBED_*), so there is no
local model-server or torch dependency β€” just the `openai` client and numpy.
"""
from __future__ import annotations
import numpy as np
from openai import OpenAI
from . import config
# One shared client, created lazily on first use (so merely importing this module
# doesn't require the endpoint to be reachable).
_client: OpenAI | None = None
def _get_client() -> OpenAI:
global _client
if _client is None:
_client = OpenAI(
base_url=config.EMBED_BASE_URL,
api_key=config.EMBED_API_KEY,
timeout=config.API_TIMEOUT,
)
return _client
def embed_texts(texts: list[str], batch_size: int = 64) -> np.ndarray:
"""Embed a list of strings into an (N, dim) float32 matrix of UNIT vectors.
The vectors are L2-normalized, which means cosine similarity between any two of
them is just their dot product. That lets retrieval score the whole corpus with a
single fast matrix multiply (see retrieve.py).
Texts are sent in batches so we make a handful of requests instead of one per
document β€” much faster for a few hundred documents.
"""
if not texts:
raise ValueError("embed_texts called with no texts")
client = _get_client()
vectors: list[list[float]] = []
for start in range(0, len(texts), batch_size):
batch = texts[start : start + batch_size]
response = client.embeddings.create(model=config.EMBEDDING_MODEL, input=batch)
# The API guarantees one embedding per input, in order.
vectors.extend(item.embedding for item in response.data)
matrix = np.asarray(vectors, dtype=np.float32)
return _normalize(matrix)
def _normalize(matrix: np.ndarray) -> np.ndarray:
"""Scale each row to unit length. Guards against divide-by-zero on empty vectors."""
norms = np.linalg.norm(matrix, axis=1, keepdims=True)
norms[norms == 0] = 1.0 # avoid NaN if a vector is all zeros
return matrix / norms