| |
| """ |
| Supabase vector search helper (RAG integration). |
| Uses the existing search_embeddings RPC already installed on the project. |
| No pinecone/weaviate needed — SQL + pgvector. |
| """ |
|
|
| import os |
| from typing import Any |
|
|
| import httpx |
| from dotenv import load_dotenv |
|
|
| |
| load_dotenv("/app/.env", override=True) |
|
|
|
|
| def _get_url(): |
| return os.environ.get("SUPABASE_URL", "") |
|
|
|
|
| def _get_key(): |
| return os.environ.get("SUPABASE_SERVICE_ROLE_KEY", "") or os.environ.get("SUPABASE_SERVICE_KEY", "") |
|
|
|
|
| def _get_headers(): |
| key = _get_key() |
| return { |
| "apikey": key, |
| "Authorization": f"Bearer {key}", |
| "Content-Type": "application/json", |
| } |
|
|
|
|
| async def search_similar( |
| query_embedding: list[float], |
| namespace: str = "default", |
| match_count: int = 10, |
| similarity_threshold: float = 0.7, |
| ) -> list[dict[str, Any]]: |
| """ |
| Search for semantically similar documents using pgvector. |
| Returns matching document IDs with similarity scores. |
| |
| Args: |
| query_embedding: The embedding vector from your model |
| namespace: Search namespace to restrict results |
| match_count: Number of results to return |
| similarity_threshold: Minimum cosine similarity (0-1) |
| """ |
| |
| from app.supabase_vector import TABLE_DIM, pad_vector |
|
|
| padded_embedding = pad_vector(query_embedding, TABLE_DIM) |
|
|
| url = f"{_get_url()}/rest/v1/rpc/search_embeddings" |
| payload = { |
| "query_embedding": padded_embedding, |
| "match_count": match_count, |
| "namespace": namespace, |
| "similarity_threshold": similarity_threshold, |
| } |
| async with httpx.AsyncClient(timeout=30) as client: |
| r = await client.post(url, json=payload, headers=_get_headers()) |
| if r.status_code == 200: |
| return r.json() |
| return [] |
|
|
|
|
| async def store_embedding( |
| document_id: str, |
| embedding: list[float], |
| namespace: str = "default", |
| content_hash: str = "", |
| metadata: dict | None = None, |
| model_name: str = "", |
| ) -> dict | None: |
| """ |
| Store an embedding for later retrieval. |
| Idempotent — uses ON CONFLICT (document_id) for upsert via REST. |
| """ |
| url = f"{_get_url()}/rest/v1/embeddings" |
| payload = { |
| "document_id": document_id, |
| "embedding": embedding, |
| "namespace": namespace, |
| "content_hash": content_hash, |
| "metadata": metadata or {}, |
| "model_name": model_name, |
| } |
| headers = dict(_get_headers()) |
| headers["Prefer"] = "resolution=merge-duplicates" |
| async with httpx.AsyncClient(timeout=30) as client: |
| r = await client.post(url, json=payload, headers=headers) |
| return r.json() if r.status_code in (200, 201) else None |
|
|
|
|
| async def get_namespace_stats(namespace: str = "default") -> dict: |
| """Get document count and stats for a namespace.""" |
| url = f"{_get_url()}/rest/v1/embeddings?namespace=eq.{namespace}&select=id" |
| async with httpx.AsyncClient(timeout=10) as client: |
| r = await client.get(url, headers={**_get_headers(), "Prefer": "count=exact"}) |
| return { |
| "namespace": namespace, |
| "count": int(r.headers.get("content-range", "0").split("/")[-1] or 0), |
| "status": r.status_code, |
| } |
|
|