Files
tirth8205--code-review-graph/code_review_graph/embeddings.py
T
2026-07-13 12:42:18 +08:00

1007 lines
38 KiB
Python

"""Vector embedding support for semantic code search.
Supports multiple providers:
1. Local (sentence-transformers) - Private, fast, offline.
2. Google Gemini - High-quality, cloud-based. Requires explicit opt-in.
3. MiniMax (embo-01) - High-quality 1536-dim cloud embeddings. Requires MINIMAX_API_KEY.
4. OpenAI-compatible - Any endpoint speaking OpenAI /v1/embeddings (real OpenAI,
Azure OpenAI, self-hosted gateways like new-api / LiteLLM / vLLM / LocalAI / Ollama).
"""
from __future__ import annotations
import hashlib
import logging
import os
import re
import sqlite3
import struct
import sys
import time
from abc import ABC, abstractmethod
from pathlib import Path
from typing import Any
from urllib.parse import urlparse
from . import __version__ as _crg_version
from .graph import GraphNode, GraphStore, node_to_dict
logger = logging.getLogger(__name__)
# Sent on every cloud-provider HTTP request. Some providers (e.g. Fireworks)
# sit behind Cloudflare and reject the urllib default ``Python-urllib/X.Y``
# UA with HTTP 403 / error 1010 ("browser signature banned"). A real UA gets
# us through and gives upstream a way to identify CRG-driven traffic.
_USER_AGENT = (
f"code-review-graph/{_crg_version} "
"(+https://github.com/tirth8205/code-review-graph)"
)
# ---------------------------------------------------------------------------
# Provider Interface and Implementations
# ---------------------------------------------------------------------------
class EmbeddingProvider(ABC):
@abstractmethod
def embed(self, texts: list[str]) -> list[list[float]]:
pass
@abstractmethod
def embed_query(self, text: str) -> list[float]:
"""Embed a search query (may use a different task type than indexing)."""
pass
@property
@abstractmethod
def dimension(self) -> int:
pass
@property
@abstractmethod
def name(self) -> str:
pass
LOCAL_DEFAULT_MODEL = "all-MiniLM-L6-v2"
# Process-wide cache of loaded sentence-transformer models, keyed by model name.
# Populated by ``prewarm_local_embeddings()`` at server startup (see ``main.main``)
# and by ``LocalEmbeddingProvider._get_model`` on first lazy load. Sharing the
# loaded model across ``LocalEmbeddingProvider`` instances avoids re-importing
# ``sentence_transformers`` + ``torch`` from worker threads, which deadlocks
# ``semantic_search_nodes_tool`` on Windows stdio MCP (#385 fixed the peer
# tools via ``asyncio.to_thread``; this cache fixes the remaining case where
# torch DLL / OpenMP init runs inside an executor thread).
_MODEL_CACHE: dict[str, Any] = {}
def prewarm_local_embeddings(model_name: str | None = None) -> None:
"""Eagerly load the local sentence-transformer model on the calling thread.
Call this from the **main thread** before entering an asyncio event loop
(e.g. before ``mcp.run()``) on Windows to prevent a deadlock where lazy-
loading ``sentence_transformers`` + ``torch`` inside a FastMCP executor
worker thread blocks indefinitely on DLL init / OpenMP thread-pool
registration.
No-op when ``sentence-transformers`` is not installed (cloud-provider
setups remain unaffected) or when the configured model is already cached.
Args:
model_name: Optional override; falls back to the ``CRG_EMBEDDING_MODEL``
environment variable and then to ``LOCAL_DEFAULT_MODEL``.
"""
try:
from sentence_transformers import SentenceTransformer # noqa: F401
except ImportError:
return # cloud-only setup: nothing to pre-warm
resolved = model_name or os.environ.get(
"CRG_EMBEDDING_MODEL", LOCAL_DEFAULT_MODEL
)
if resolved in _MODEL_CACHE:
return
try:
_MODEL_CACHE[resolved] = LocalEmbeddingProvider(resolved)._get_model()
except Exception as exc: # pragma: no cover — best-effort startup hook
logger.warning("prewarm_local_embeddings(%s) skipped: %s", resolved, exc)
class LocalEmbeddingProvider(EmbeddingProvider):
def __init__(self, model_name: str | None = None) -> None:
self._model_name = model_name or os.environ.get(
"CRG_EMBEDDING_MODEL", LOCAL_DEFAULT_MODEL
)
self._model = None # Lazy-loaded
def _get_model(self):
if self._model is None:
# Check the process-wide cache first — populated either by a prior
# provider instance or by ``prewarm_local_embeddings`` at startup.
cached = _MODEL_CACHE.get(self._model_name)
if cached is not None:
self._model = cached
return self._model
try:
from sentence_transformers import SentenceTransformer
# Check environment variable, default to False to prevent RCE
_rce_val = os.environ.get("CRG_ALLOW_REMOTE_CODE", "0")
allow_remote_code = _rce_val.lower() in ("1", "true", "yes")
self._model = SentenceTransformer(
self._model_name,
trust_remote_code=allow_remote_code,
)
_MODEL_CACHE[self._model_name] = self._model
except ImportError:
raise ImportError(
"sentence-transformers not installed. "
"Run: pip install code-review-graph[embeddings]"
)
return self._model
def embed(self, texts: list[str]) -> list[list[float]]:
model = self._get_model()
vectors = model.encode(texts, show_progress_bar=False)
return [v.tolist() for v in vectors]
def embed_query(self, text: str) -> list[float]:
return self.embed([text])[0]
@property
def dimension(self) -> int:
model = self._get_model()
return model.get_sentence_embedding_dimension()
@property
def name(self) -> str:
return f"local:{self._model_name}"
class GoogleEmbeddingProvider(EmbeddingProvider):
def __init__(self, api_key: str, model: str = "gemini-embedding-001") -> None:
try:
from google import genai
self._client = genai.Client(api_key=api_key)
self.model = model
self._dimension: int | None = None
except ImportError:
raise ImportError(
"google-generativeai not installed. "
"Run: pip install code-review-graph[google-embeddings]"
)
def embed(self, texts: list[str]) -> list[list[float]]:
batch_size = 100
results = []
for i in range(0, len(texts), batch_size):
batch = texts[i:i + batch_size]
response = self._call_with_retry(
lambda b=batch: self._client.models.embed_content(
model=self.model,
contents=b,
config={"task_type": "RETRIEVAL_DOCUMENT"},
)
)
results.extend([e.values for e in response.embeddings])
if self._dimension is None and results:
self._dimension = len(results[0])
return results
@staticmethod
def _call_with_retry(fn, max_retries: int = 3):
"""Call fn with exponential backoff on transient API errors."""
for attempt in range(max_retries):
try:
return fn()
except Exception as e:
# Retry on rate-limit (429) or server errors (5xx)
err_str = str(e)
is_retryable = "429" in err_str or "500" in err_str or "503" in err_str
if not is_retryable or attempt == max_retries - 1:
raise
wait = 2 ** attempt
logger.warning("Gemini API error (attempt %d/%d), retrying in %ds: %s",
attempt + 1, max_retries, wait, e)
time.sleep(wait)
def embed_query(self, text: str) -> list[float]:
response = self._call_with_retry(
lambda: self._client.models.embed_content(
model=self.model,
contents=[text],
config={"task_type": "RETRIEVAL_QUERY"},
)
)
vec = response.embeddings[0].values
if self._dimension is None:
self._dimension = len(vec)
return vec
@property
def dimension(self) -> int:
if self._dimension is not None:
return self._dimension
# Default for gemini-embedding-001; updated dynamically after first call
return 768
@property
def name(self) -> str:
return f"google:{self.model}"
class MiniMaxEmbeddingProvider(EmbeddingProvider):
"""MiniMax embo-01 embedding provider (1536 dimensions).
Uses the MiniMax Embeddings API (https://api.minimax.io/v1/embeddings)
with the embo-01 model. Requires the MINIMAX_API_KEY environment variable.
"""
_ENDPOINT = "https://api.minimax.io/v1/embeddings"
_MODEL = "embo-01"
_DIMENSION = 1536
def __init__(self, api_key: str) -> None:
self._api_key = api_key
def _call_api(self, texts: list[str], task_type: str) -> list[list[float]]:
import json as _json
import urllib.request
payload = _json.dumps({
"model": self._MODEL,
"texts": texts,
"type": task_type,
}).encode("utf-8")
req = urllib.request.Request(
self._ENDPOINT,
data=payload,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {self._api_key}",
"User-Agent": _USER_AGENT,
"Accept": "application/json",
},
)
max_retries = 3
for attempt in range(max_retries):
try:
import ssl
_ssl_ctx = ssl.create_default_context()
with urllib.request.urlopen(req, timeout=60, context=_ssl_ctx) as resp: # nosec B310
body = _json.loads(resp.read().decode("utf-8"))
base_resp = body.get("base_resp", {})
if base_resp.get("status_code", 0) != 0:
raise RuntimeError(
f"MiniMax API error: {base_resp.get('status_msg', 'unknown')}"
)
return body["vectors"]
except Exception as e:
err_str = str(e)
is_retryable = "429" in err_str or "500" in err_str or "503" in err_str
if not is_retryable or attempt == max_retries - 1:
raise
wait = 2 ** attempt
logger.warning(
"MiniMax API error (attempt %d/%d), retrying in %ds: %s",
attempt + 1, max_retries, wait, e,
)
time.sleep(wait)
return [] # unreachable, but keeps mypy happy
def embed(self, texts: list[str]) -> list[list[float]]:
batch_size = 100
results: list[list[float]] = []
for i in range(0, len(texts), batch_size):
batch = texts[i:i + batch_size]
results.extend(self._call_api(batch, "db"))
return results
def embed_query(self, text: str) -> list[float]:
return self._call_api([text], "query")[0]
@property
def dimension(self) -> int:
return self._DIMENSION
@property
def name(self) -> str:
return f"minimax:{self._MODEL}"
class OpenAIEmbeddingProvider(EmbeddingProvider):
"""OpenAI-compatible embedding provider.
Works with any endpoint that speaks the OpenAI ``/v1/embeddings`` schema:
- Real OpenAI API (``https://api.openai.com/v1``)
- Azure OpenAI
- Self-hosted gateways: new-api, LiteLLM, vLLM, LocalAI, Ollama (openai mode)
Provider identity in ``name`` includes both the model and the endpoint
host (``openai:{model}@{host}``), so switching base URL while keeping the
same model ID re-partitions the embeddings table and forces a clean
re-embed. This is the only defense against silently mixing vector spaces
from different backends (e.g. real OpenAI vs. an OpenAI-compatible
gateway that ships different weights under the same model name).
Dimension is detected from the first response and frozen; switching the
``model`` in the environment also changes ``provider.name`` and triggers
re-embed via the same isolation key.
"""
_DEFAULT_BATCH_SIZE = 100
# Default ports by scheme; stripped from the host_key so the user can't
# accidentally force a re-embed by toggling an explicit default port.
_DEFAULT_PORTS = {"http": 80, "https": 443}
def __init__(
self,
api_key: str,
base_url: str,
model: str,
dimension: int | None = None,
timeout: int = 120,
batch_size: int | None = None,
) -> None:
self._api_key = api_key
self._base_url = base_url.rstrip("/")
self._model = model
self._dimension = dimension
self._timeout = timeout
self._batch_size = batch_size or self._DEFAULT_BATCH_SIZE
self._host_key = self._make_host_key(self._base_url)
@classmethod
def _make_host_key(cls, base_url: str) -> str:
"""Normalize the identity key used in ``provider.name``.
Codex review pushed this well past naive ``netloc`` because that
alone has three leaks:
1. ``netloc`` preserves ``userinfo`` (``user:pass@host``) — we'd
persist credentials into the DB's ``embeddings.provider`` column.
Use ``hostname`` instead.
2. Default ports (``:80`` for http, ``:443`` for https) are
semantically identical to omitting the port; keeping them would
cause spurious re-embeds when the user just spelled the URL
differently.
3. Path is part of the backend identity for path-routed gateways:
``https://gw/openai/v1`` and ``https://gw/vendor-b/v1`` front
different models and must not share cached vectors.
"""
parsed = urlparse(base_url)
hostname = (parsed.hostname or "").lower()
scheme = (parsed.scheme or "").lower()
port = parsed.port
if port and port != cls._DEFAULT_PORTS.get(scheme):
# Bracket IPv6 literals when appending a port.
host_part = f"[{hostname}]:{port}" if ":" in hostname else f"{hostname}:{port}"
else:
host_part = hostname
# Preserve path routing. Trim any trailing slash and any
# ``/embeddings`` suffix that callers may have included — we append
# that ourselves when building the request URL.
path = (parsed.path or "").rstrip("/")
if path.endswith("/embeddings"):
path = path[: -len("/embeddings")].rstrip("/")
# Include scheme: http and https to the same host+path front
# different endpoints in practice (plaintext vs TLS, dev vs prod
# gateway), and sharing cached vectors across them is the same
# silent-mixing failure mode as switching base URL entirely.
return f"{scheme}://{host_part}{path}" if path else f"{scheme}://{host_part}"
def _call_api(self, texts: list[str]) -> list[list[float]]:
import http.client
import json as _json
import socket
import ssl
import urllib.error
import urllib.request
body: dict[str, Any] = {"model": self._model, "input": texts}
# OpenAI v3 models (text-embedding-3-*) support dimension reduction;
# only forward the param when the user explicitly pinned one.
if self._dimension is not None:
body["dimensions"] = self._dimension
payload = _json.dumps(body).encode("utf-8")
req = urllib.request.Request(
f"{self._base_url}/embeddings",
data=payload,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {self._api_key}",
"User-Agent": _USER_AGENT,
"Accept": "application/json",
},
)
max_retries = 3
for attempt in range(max_retries):
try:
_ssl_ctx = ssl.create_default_context()
try:
with urllib.request.urlopen( # nosec B310
req, timeout=self._timeout, context=_ssl_ctx,
) as resp:
raw = resp.read().decode("utf-8")
except urllib.error.HTTPError as http_err:
# 429 / 5xx: re-raise and let the outer retry loop handle it.
# (We must not convert to RuntimeError here or retry below
# can't tell it was a transient HTTP failure.)
if http_err.code == 429 or 500 <= http_err.code < 600:
raise
# Other 4xx: surface the API error body instead of a bare
# "400 Bad Request" — gateways like new-api return JSON
# with the real reason (batch size limits, invalid model,
# etc.) which is far more actionable.
try:
err_body = http_err.read().decode("utf-8", errors="replace")
except Exception:
err_body = ""
err_msg = err_body or str(http_err)
try:
parsed = _json.loads(err_body)
if isinstance(parsed, dict) and "error" in parsed:
err_obj = parsed["error"]
err_msg = (
err_obj.get("message", err_msg)
if isinstance(err_obj, dict) else str(err_obj)
)
except Exception: # nosec B110
# Non-JSON error body is fine: we already seeded
# err_msg with the raw body above, so fall through.
pass
raise RuntimeError(
f"OpenAI API HTTP {http_err.code}: {err_msg}"
) from http_err
response = _json.loads(raw)
if "error" in response:
err = response["error"]
msg = err.get("message", "unknown") if isinstance(err, dict) else str(err)
raise RuntimeError(f"OpenAI API error: {msg}")
data = response.get("data", [])
if not data:
raise RuntimeError("OpenAI API returned empty data")
# OpenAI spec: data[i].index maps to input[i], but some
# compatible gateways re-order results or drop entries on
# partial failure, and others omit `index` entirely. Three
# disjoint cases:
# 1. All items have a valid int ``index``: must form a
# permutation of 0..N-1, then sort and use.
# 2. NO item carries an ``index`` field: trust server
# order, only verify count matches.
# 3. Anything in between (partial indices, str indices,
# missing on some): refuse. Zipping server order in
# that case would happily misalign the indexed items.
any_has_index = any("index" in item for item in data)
all_int_index = all(
isinstance(item.get("index"), int) for item in data
)
if all_int_index:
expected = set(range(len(texts)))
indices = [int(item["index"]) for item in data]
if len(set(indices)) != len(indices) or set(indices) != expected:
raise RuntimeError(
"OpenAI API returned malformed indices "
f"(got {indices}, expected permutation of "
f"0..{len(texts) - 1}) — refusing to misalign vectors."
)
data = sorted(data, key=lambda item: int(item["index"]))
elif not any_has_index:
if len(data) != len(texts):
raise RuntimeError(
f"OpenAI API returned {len(data)} embeddings for "
f"{len(texts)} inputs with no index field — "
"refusing to misalign vectors."
)
else:
# Mixed: some items have index, others don't (or carry
# non-int index). Server order would silently misplace
# the indexed items, so we refuse.
raise RuntimeError(
"OpenAI API returned mixed indexed/unindexed data — "
"refusing to misalign vectors."
)
vectors = [item["embedding"] for item in data]
if vectors and self._dimension is None:
self._dimension = len(vectors[0])
return vectors
except Exception as e:
# Retryable = HTTP 429/5xx, network/timeout/TLS issues.
# Non-retryable = HTTP 4xx (other), malformed responses,
# misaligned data length — those are caller-side bugs that
# will keep failing on retry.
is_retryable = False
if isinstance(e, urllib.error.HTTPError):
is_retryable = e.code == 429 or 500 <= e.code < 600
elif isinstance(e, (
urllib.error.URLError,
socket.timeout,
TimeoutError,
ConnectionError,
ssl.SSLError,
# Reverse proxies and edge gateways surface transient
# disconnects as these stdlib classes. Real incidents
# have been observed on Cloudflare-fronted endpoints
# and on LiteLLM when upstream providers hiccup.
http.client.IncompleteRead,
http.client.BadStatusLine,
http.client.RemoteDisconnected,
)):
is_retryable = True
if not is_retryable or attempt == max_retries - 1:
raise
wait = 2 ** attempt
logger.warning(
"OpenAI embeddings API error (attempt %d/%d), retrying in %ds: %s",
attempt + 1, max_retries, wait, e,
)
time.sleep(wait)
return [] # unreachable
def embed(self, texts: list[str]) -> list[list[float]]:
if not texts:
return []
results: list[list[float]] = []
for i in range(0, len(texts), self._batch_size):
results.extend(self._call_api(texts[i:i + self._batch_size]))
return results
def embed_query(self, text: str) -> list[float]:
return self._call_api([text])[0]
@property
def dimension(self) -> int:
if self._dimension is not None:
return self._dimension
# Default for text-embedding-3-small; updated after first call.
return 1536
@property
def name(self) -> str:
# Endpoint-aware identity: model alone is NOT enough — two backends
# can serve the same model ID with different weights or dimensions,
# and re-using cached embeddings across them silently corrupts
# semantic ranking. Including the host partitions the embeddings
# table so switching CRG_OPENAI_BASE_URL triggers a safe re-embed.
return f"openai:{self._model}@{self._host_key}"
CLOUD_PROVIDERS = {"google", "minimax", "openai"}
def _is_localhost_url(url: str) -> bool:
"""Return True if url points to a localhost host (never treat as cloud egress).
Uses urlparse.hostname so we compare the actual host, not a substring
match that could be fooled by e.g. ``https://my-openai.127.0.0.1.nip.io``.
"""
try:
host = (urlparse(url).hostname or "").lower()
except Exception:
return False
# nosec B104: we're *matching* a URL hostname, not binding a listener.
return host in {"127.0.0.1", "localhost", "0.0.0.0", "::1"} # nosec B104
def _warn_cloud_egress(provider_name: str) -> None:
"""Print a stderr warning before a cloud embedding provider is used.
The warning is suppressed when ``CRG_ACCEPT_CLOUD_EMBEDDINGS=1`` is
set in the environment, so scripted / CI workloads can acknowledge
once and move on. Use stderr (never stdin/input) to stay compatible
with the MCP stdio transport — anything we write to stdout would
corrupt the JSON-RPC stream. See: #174
"""
if os.environ.get("CRG_ACCEPT_CLOUD_EMBEDDINGS", "").strip() == "1":
return
print(
f"\n⚠️ code-review-graph: about to embed code via the '{provider_name}' "
"cloud provider.\n"
" Your source code (function names, docstrings, file paths) will be "
"sent to an external API.\n"
" This is necessary for semantic search with the cloud provider you "
"selected.\n"
" To skip this warning in future runs, set "
"CRG_ACCEPT_CLOUD_EMBEDDINGS=1 in your environment.\n"
" To stay fully offline, use the default 'local' provider instead "
"(no API key needed).\n",
file=sys.stderr,
)
_VALID_PROVIDERS = {"local", "openai", "google", "minimax"}
def get_provider(
provider: str | None = None,
model: str | None = None,
) -> EmbeddingProvider | None:
"""Get an embedding provider by name.
Args:
provider: Provider name. One of "local", "google", "minimax", "openai",
or None for local. Names are case-insensitive and surrounding
whitespace is ignored; unknown names raise ValueError instead
of silently falling back to the local provider.
Google requires GOOGLE_API_KEY env var and explicit opt-in.
MiniMax requires MINIMAX_API_KEY env var and explicit opt-in.
OpenAI requires CRG_OPENAI_API_KEY + CRG_OPENAI_BASE_URL +
CRG_OPENAI_MODEL env vars (or the ``model`` arg). The egress
warning is skipped when the base URL points to localhost.
Cloud providers emit a one-time stderr warning before use
unless ``CRG_ACCEPT_CLOUD_EMBEDDINGS=1`` is set. See: #174
model: Model name/path to use. For local provider this is any
sentence-transformers compatible model. Falls back to
CRG_EMBEDDING_MODEL env var, then to all-MiniLM-L6-v2.
For Google provider this is a Gemini model ID.
For OpenAI provider this overrides CRG_OPENAI_MODEL.
Raises:
ValueError: If the provider name is not one of the known providers,
or if required environment variables are missing.
"""
name = provider.strip().lower() if provider else ""
if name and name not in _VALID_PROVIDERS:
raise ValueError(
f"Unknown embedding provider '{name}'. "
"Valid: local, openai, google, minimax"
)
if name == "openai":
api_key = os.environ.get("CRG_OPENAI_API_KEY")
base_url = os.environ.get("CRG_OPENAI_BASE_URL")
resolved_model = model or os.environ.get("CRG_OPENAI_MODEL")
if not api_key or not base_url or not resolved_model:
missing = [
name for name, val in [
("CRG_OPENAI_API_KEY", api_key),
("CRG_OPENAI_BASE_URL", base_url),
("CRG_OPENAI_MODEL", resolved_model),
] if not val
]
raise ValueError(
"Missing required environment variable(s) for the OpenAI "
f"embedding provider: {', '.join(missing)}."
)
dim_env = os.environ.get("CRG_OPENAI_DIMENSION")
dimension = int(dim_env) if dim_env else None
batch_env = os.environ.get("CRG_OPENAI_BATCH_SIZE")
batch_size = int(batch_env) if batch_env else None
if not _is_localhost_url(base_url):
_warn_cloud_egress("openai")
return OpenAIEmbeddingProvider(
api_key=api_key,
base_url=base_url,
model=resolved_model,
dimension=dimension,
batch_size=batch_size,
)
if name == "minimax":
api_key = os.environ.get("MINIMAX_API_KEY")
if not api_key:
raise ValueError(
"MINIMAX_API_KEY environment variable is required for "
"the MiniMax embedding provider."
)
_warn_cloud_egress("minimax")
return MiniMaxEmbeddingProvider(api_key=api_key)
if name == "google":
api_key = os.environ.get("GOOGLE_API_KEY")
if not api_key:
raise ValueError(
"GOOGLE_API_KEY environment variable is required for "
"the Google embedding provider."
)
_warn_cloud_egress("google")
try:
return GoogleEmbeddingProvider(
api_key=api_key,
**({"model": model} if model else {}),
)
except ImportError:
return None
# Default: local
if not _check_available():
return None
try:
return LocalEmbeddingProvider(model_name=model)
except ImportError:
return None
def _check_available() -> bool:
"""Check whether local embedding support is available."""
try:
import sentence_transformers # noqa: F401
return True
except ImportError:
return False
# ---------------------------------------------------------------------------
# SQLite vector storage
# ---------------------------------------------------------------------------
_EMBEDDINGS_SCHEMA = """
CREATE TABLE IF NOT EXISTS embeddings (
qualified_name TEXT PRIMARY KEY,
vector BLOB NOT NULL,
text_hash TEXT NOT NULL,
provider TEXT NOT NULL DEFAULT 'unknown'
);
"""
def _encode_vector(vec: list[float]) -> bytes:
"""Encode a float vector as a compact binary blob."""
return struct.pack(f"{len(vec)}f", *vec)
def _decode_vector(blob: bytes) -> list[float]:
"""Decode a binary blob back to a float vector."""
n = len(blob) // 4 # 4 bytes per float32
return list(struct.unpack(f"{n}f", blob))
def _cosine_similarity(a: list[float], b: list[float]) -> float:
"""Compute cosine similarity between two vectors."""
if len(a) != len(b):
return 0.0
dot = sum(x * y for x, y in zip(a, b))
norm_a = sum(x * x for x in a) ** 0.5
norm_b = sum(x * x for x in b) ** 0.5
if norm_a == 0 or norm_b == 0:
return 0.0
return dot / (norm_a * norm_b)
_IDENTIFIER_SPLIT_RE = re.compile(r"([a-z])([A-Z])|[_./\-]+")
def _split_identifier(name: str) -> str:
"""Split snake_case / camelCase / PascalCase / dotted into space-separated words.
Examples:
get_route_handler -> "get route handler"
APIRoute -> "API Route"
dispatch_request -> "dispatch request"
full_dispatch_request -> "full dispatch request"
"""
if not name:
return ""
# Insert space between lowercase->uppercase transitions, then collapse
# snake_case / dotted / hyphenated separators.
spaced = re.sub(r"([a-z])([A-Z])", r"\1 \2", name)
spaced = re.sub(r"[_./\-]+", " ", spaced)
return " ".join(spaced.split())
def _node_to_text(node: GraphNode) -> str:
"""Convert a node to a searchable text representation.
Designed so natural-language queries land on the right node, not just on
the enclosing class. We include the dotted ``Parent.name`` form, the
identifier split into words, an explicit ``"in <Parent>"`` phrase, the
enclosing module directory, and the language. Tested by the
``multi_hop_retrieval`` benchmark — see ``docs/REPRODUCING.md``.
"""
parts: list[str] = []
# 1. Dotted form first — strongest lexical signal for "method in class"
if node.parent_name and node.kind != "File":
parts.append(f"{node.parent_name}.{node.name}")
# 2. Bare name (always present)
parts.append(node.name)
# 3. Split-words form of the name (only if it differs from the bare name)
name_split = _split_identifier(node.name)
if name_split and name_split.lower() != node.name.lower():
parts.append(name_split)
# 4. Kind ("function", "class", "test", ...)
if node.kind != "File":
parts.append(node.kind.lower())
# 5. Parent context with the split form too
if node.parent_name:
parts.append(f"in {node.parent_name}")
parent_split = _split_identifier(node.parent_name)
if parent_split and parent_split.lower() != node.parent_name.lower():
parts.append(parent_split)
# 6. Signature bits
if node.params:
parts.append(node.params)
if node.return_type:
parts.append(f"returns {node.return_type}")
# 7. Module / directory context from the file path — gives queries a
# term like "routing" or "client" to anchor against.
if node.file_path:
parent_dir = Path(node.file_path).parent.name
if parent_dir and parent_dir not in (".", "src", "lib"):
parts.append(parent_dir)
# 8. Language
if node.language:
parts.append(node.language)
return " ".join(parts)
class EmbeddingStore:
"""Manages vector embeddings for graph nodes in SQLite."""
def __init__(
self,
db_path: str | Path,
provider: str | None = None,
model: str | None = None,
) -> None:
self.provider = get_provider(provider, model=model)
self.available = self.provider is not None
self.db_path = Path(db_path)
self._conn = sqlite3.connect(
str(self.db_path), timeout=30, check_same_thread=False,
isolation_level=None,
)
self._conn.row_factory = sqlite3.Row
self._conn.executescript(_EMBEDDINGS_SCHEMA)
# Migration for existing DBs missing the provider column
try:
self._conn.execute("SELECT provider FROM embeddings LIMIT 1")
except sqlite3.OperationalError:
self._conn.execute(
"ALTER TABLE embeddings ADD COLUMN provider "
"TEXT NOT NULL DEFAULT 'unknown'"
)
self._conn.commit()
def __enter__(self) -> "EmbeddingStore":
return self
def __exit__(self, exc_type, exc_val, exc_tb) -> None: # type: ignore[no-untyped-def]
self.close()
def close(self) -> None:
self._conn.close()
def embed_nodes(self, nodes: list[GraphNode], batch_size: int = 64) -> int:
"""Compute and store embeddings for a list of nodes."""
if not self.provider:
return 0
# Filter to nodes that need embedding
to_embed: list[tuple[GraphNode, str, str]] = []
provider_name = self.provider.name
for node in nodes:
if node.kind == "File":
continue
text = _node_to_text(node)
text_hash = hashlib.sha256(text.encode()).hexdigest()
existing = self._conn.execute(
"SELECT text_hash, provider FROM embeddings WHERE qualified_name = ?",
(node.qualified_name,),
).fetchone()
# Re-embed if text changed OR provider changed
if (existing and existing["text_hash"] == text_hash
and existing["provider"] == provider_name):
continue
to_embed.append((node, text, text_hash))
if not to_embed:
return 0
# Encode in batches
texts = [t for _, t, _ in to_embed]
vectors = self.provider.embed(texts)
for (node, _text, text_hash), vec in zip(to_embed, vectors):
blob = _encode_vector(vec)
self._conn.execute(
"""INSERT OR REPLACE INTO embeddings (qualified_name, vector, text_hash, provider)
VALUES (?, ?, ?, ?)""",
(node.qualified_name, blob, text_hash, provider_name),
)
self._conn.commit()
return len(to_embed)
def search(self, query: str, limit: int = 20) -> list[tuple[str, float]]:
"""Search for nodes by semantic similarity."""
if not self.provider:
return []
provider_name = self.provider.name
query_vec = self.provider.embed_query(query)
# Process in chunks, only matching current provider
scored: list[tuple[str, float]] = []
cursor = self._conn.execute(
"SELECT qualified_name, vector FROM embeddings WHERE provider = ?",
(provider_name,),
)
chunk_size = 500
while True:
rows = cursor.fetchmany(chunk_size)
if not rows:
break
for row in rows:
vec = _decode_vector(row["vector"])
sim = _cosine_similarity(query_vec, vec)
scored.append((row["qualified_name"], sim))
scored.sort(key=lambda x: x[1], reverse=True)
return scored[:limit]
def remove_node(self, qualified_name: str) -> None:
self._conn.execute(
"DELETE FROM embeddings WHERE qualified_name = ?", (qualified_name,)
)
self._conn.commit()
def count(self) -> int:
return self._conn.execute("SELECT COUNT(*) FROM embeddings").fetchone()[0]
def embed_all_nodes(graph_store: GraphStore, embedding_store: EmbeddingStore) -> int:
"""Embed all non-file nodes in the graph."""
if not embedding_store.available:
return 0
all_files = graph_store.get_all_files()
all_nodes: list[GraphNode] = []
for f in all_files:
all_nodes.extend(graph_store.get_nodes_by_file(f))
return embedding_store.embed_nodes(all_nodes)
def semantic_search(
query: str,
graph_store: GraphStore,
embedding_store: EmbeddingStore,
limit: int = 20,
) -> list[dict[str, Any]]:
"""Search nodes using vector similarity, falling back to keyword search."""
if embedding_store.available and embedding_store.count() > 0:
results = embedding_store.search(query, limit=limit)
output = []
for qn, score in results:
node = graph_store.get_node(qn)
if node:
d = node_to_dict(node)
d["similarity_score"] = round(score, 4)
output.append(d)
return output
# Fallback to keyword search
nodes = graph_store.search_nodes(query, limit=limit)
return [node_to_dict(n) for n in nodes]