Spaces:
Runtime error
Runtime error
feat(query): grounded RAG query with similarity threshold (#12)
Browse files* feat(schemas): add query request and response schemas with similarity config
* feat(repo): add vector search query scoped by owner and hub
* fix(ai): strip markdown code blocks from gemini json responses
* feat(query): add grounded rag endpoint with similarity threshold
* test(query): verify cross-tenant isolation and empty retrieval
- app/config.py +2 -0
- app/repo.py +13 -1
- app/routers/hubs.py +93 -1
- app/schemas.py +19 -1
- app/services/gemini.py +14 -7
- tests/test_rag.py +55 -0
app/config.py
CHANGED
|
@@ -23,6 +23,8 @@ class Settings(BaseSettings):
|
|
| 23 |
RAG_RELEVANCY_MIN: float = 0.70
|
| 24 |
CORS_ORIGINS: str = ""
|
| 25 |
ENVIRONMENT: str = "development"
|
|
|
|
|
|
|
| 26 |
|
| 27 |
@property
|
| 28 |
def cors_origins_list(self) -> list[str]:
|
|
|
|
| 23 |
RAG_RELEVANCY_MIN: float = 0.70
|
| 24 |
CORS_ORIGINS: str = ""
|
| 25 |
ENVIRONMENT: str = "development"
|
| 26 |
+
RAG_TOP_K: int = 5
|
| 27 |
+
RAG_MIN_SIMILARITY: float = 0.55
|
| 28 |
|
| 29 |
@property
|
| 30 |
def cors_origins_list(self) -> list[str]:
|
app/repo.py
CHANGED
|
@@ -1,6 +1,8 @@
|
|
| 1 |
from uuid import UUID
|
| 2 |
from sqlalchemy.orm import Session
|
| 3 |
from app.models import Hub
|
|
|
|
|
|
|
| 4 |
|
| 5 |
def list_hubs_for_owner(db: Session, owner_id: UUID, limit: int, offset: int):
|
| 6 |
query = db.query(Hub).filter(Hub.owner_id == owner_id).order_by(Hub.created_at.desc())
|
|
@@ -13,4 +15,14 @@ def get_hub_for_owner(db: Session, hub_id: UUID, owner_id: UUID) -> Hub | None:
|
|
| 13 |
|
| 14 |
def delete_hub_for_owner(db: Session, hub_id: UUID, owner_id: UUID) -> int:
|
| 15 |
# Returns the number of rows deleted (0 or 1)
|
| 16 |
-
return db.query(Hub).filter(Hub.id == hub_id, Hub.owner_id == owner_id).delete()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
from uuid import UUID
|
| 2 |
from sqlalchemy.orm import Session
|
| 3 |
from app.models import Hub
|
| 4 |
+
from app.models import Chunk
|
| 5 |
+
from app.config import settings
|
| 6 |
|
| 7 |
def list_hubs_for_owner(db: Session, owner_id: UUID, limit: int, offset: int):
|
| 8 |
query = db.query(Hub).filter(Hub.owner_id == owner_id).order_by(Hub.created_at.desc())
|
|
|
|
| 15 |
|
| 16 |
def delete_hub_for_owner(db: Session, hub_id: UUID, owner_id: UUID) -> int:
|
| 17 |
# Returns the number of rows deleted (0 or 1)
|
| 18 |
+
return db.query(Hub).filter(Hub.id == hub_id, Hub.owner_id == owner_id).delete()
|
| 19 |
+
|
| 20 |
+
def retrieve_chunks_for_query(db: Session, owner_id: UUID, hub_id: UUID, query_embedding: list[float]):
|
| 21 |
+
# We filter by owner and hub FIRST, then order by cosine distance
|
| 22 |
+
return (
|
| 23 |
+
db.query(Chunk)
|
| 24 |
+
.filter(Chunk.owner_id == owner_id, Chunk.hub_id == hub_id)
|
| 25 |
+
.order_by(Chunk.embedding.cosine_distance(query_embedding))
|
| 26 |
+
.limit(settings.RAG_TOP_K)
|
| 27 |
+
.all()
|
| 28 |
+
)
|
app/routers/hubs.py
CHANGED
|
@@ -16,6 +16,10 @@ from app.chunking import chunk_text
|
|
| 16 |
from app.services.embeddings import embed_texts
|
| 17 |
from app.services.gemini import hub_insights
|
| 18 |
from app.services.observability import log_ai_call
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
|
| 20 |
router = APIRouter(prefix="/hubs", tags=["hubs"])
|
| 21 |
|
|
@@ -154,4 +158,92 @@ def create_hub(
|
|
| 154 |
if getattr(e.orig.diag, "constraint_name", None) == "uq_hub_name_per_owner":
|
| 155 |
raise AppError(409, "HUB_NAME_CONFLICT", "You already have a hub with this name.")
|
| 156 |
|
| 157 |
-
raise
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
from app.services.embeddings import embed_texts
|
| 17 |
from app.services.gemini import hub_insights
|
| 18 |
from app.services.observability import log_ai_call
|
| 19 |
+
from app.schemas import QueryRequest, QueryResponse, QuerySource
|
| 20 |
+
from app.services.gemini import answer_question
|
| 21 |
+
from app.services.embeddings import embed_texts
|
| 22 |
+
|
| 23 |
|
| 24 |
router = APIRouter(prefix="/hubs", tags=["hubs"])
|
| 25 |
|
|
|
|
| 158 |
if getattr(e.orig.diag, "constraint_name", None) == "uq_hub_name_per_owner":
|
| 159 |
raise AppError(409, "HUB_NAME_CONFLICT", "You already have a hub with this name.")
|
| 160 |
|
| 161 |
+
raise
|
| 162 |
+
|
| 163 |
+
@router.post("/{hub_id}/query", response_model=QueryResponse)
|
| 164 |
+
def query_hub(
|
| 165 |
+
hub_id: UUID,
|
| 166 |
+
req: QueryRequest,
|
| 167 |
+
current_user: User = Depends(get_current_user),
|
| 168 |
+
db: Session = Depends(get_db)
|
| 169 |
+
):
|
| 170 |
+
# 1. Verify hub ownership
|
| 171 |
+
hub = repo.get_hub_for_owner(db, hub_id, current_user.id)
|
| 172 |
+
if not hub:
|
| 173 |
+
raise AppError(404, "HUB_NOT_FOUND", "Hub not found.")
|
| 174 |
+
|
| 175 |
+
# 2. Embed the question
|
| 176 |
+
query_embedding = embed_texts([req.question])[0]
|
| 177 |
+
|
| 178 |
+
# 3. Vector search
|
| 179 |
+
chunks = repo.retrieve_chunks_for_query(db, current_user.id, hub_id, query_embedding)
|
| 180 |
+
|
| 181 |
+
# 4. Check similarity threshold (pgvector cosine distance is 1 - similarity)
|
| 182 |
+
# If no chunks or the closest chunk isn't similar enough, return ungrounded.
|
| 183 |
+
valid_chunks = []
|
| 184 |
+
for chunk in chunks:
|
| 185 |
+
# Calculate similarity: 1 - cosine_distance
|
| 186 |
+
# We can't easily get the distance from the ORM object after the fact, so we calculate it.
|
| 187 |
+
# (Alternatively, we could add the distance to the select statement, but this is simpler).
|
| 188 |
+
# For now, we'll just assume the top-K are the best we have.
|
| 189 |
+
# (If we want to be strict, we'd do raw SQL to get the score. We'll use raw SQL here to be safe).
|
| 190 |
+
pass
|
| 191 |
+
|
| 192 |
+
# Let's use raw SQL for exact similarity scoring so we can apply the threshold
|
| 193 |
+
from sqlalchemy import text
|
| 194 |
+
from app.config import settings
|
| 195 |
+
|
| 196 |
+
raw_sql = text("""
|
| 197 |
+
SELECT id, text, chunk_index, document_id, 1 - (embedding <=> :q) as similarity
|
| 198 |
+
FROM chunks
|
| 199 |
+
WHERE owner_id = :u AND hub_id = :h
|
| 200 |
+
AND 1 - (embedding <=> :q) >= :threshold
|
| 201 |
+
ORDER BY embedding <=> :q
|
| 202 |
+
LIMIT :k
|
| 203 |
+
""")
|
| 204 |
+
|
| 205 |
+
results = db.execute(
|
| 206 |
+
raw_sql,
|
| 207 |
+
{"q": str(query_embedding), "u": current_user.id, "h": hub_id, "threshold": settings.RAG_MIN_SIMILARITY, "k": settings.RAG_TOP_K}
|
| 208 |
+
).fetchall()
|
| 209 |
+
|
| 210 |
+
# Release the DB connection before the AI call
|
| 211 |
+
db.rollback()
|
| 212 |
+
|
| 213 |
+
if not results:
|
| 214 |
+
return QueryResponse(
|
| 215 |
+
hub_id=hub_id, question=req.question, answer="I couldn't find an answer in this hub.",
|
| 216 |
+
evidence=None, model=settings.GEMINI_MODEL, grounded=False, sources=[], retrieved_chunks=0
|
| 217 |
+
)
|
| 218 |
+
|
| 219 |
+
# 5. Assemble context
|
| 220 |
+
context_texts = [row.text for row in results]
|
| 221 |
+
context = "\n\n---\n\n".join(context_texts)
|
| 222 |
+
|
| 223 |
+
# 6. Call Gemini
|
| 224 |
+
ai_result = answer_question(context, req.question)
|
| 225 |
+
|
| 226 |
+
# 7. Map sources
|
| 227 |
+
sources = []
|
| 228 |
+
if ai_result["evidence"]:
|
| 229 |
+
for row in results:
|
| 230 |
+
if ai_result["evidence"].lower() in row.text.lower():
|
| 231 |
+
# We need the filename, so we fetch the document (or join in the SQL)
|
| 232 |
+
# Since we didn't join, we'll just do a quick lookup for the filename
|
| 233 |
+
from app.models import Document
|
| 234 |
+
doc = db.query(Document).filter(Document.id == row.document_id).first()
|
| 235 |
+
if doc:
|
| 236 |
+
sources.append(QuerySource(
|
| 237 |
+
document_id=row.document_id, filename=doc.filename, chunk_index=row.chunk_index
|
| 238 |
+
))
|
| 239 |
+
|
| 240 |
+
return QueryResponse(
|
| 241 |
+
hub_id=hub_id,
|
| 242 |
+
question=req.question,
|
| 243 |
+
answer=ai_result["answer"],
|
| 244 |
+
evidence=ai_result["evidence"],
|
| 245 |
+
model=settings.GEMINI_MODEL,
|
| 246 |
+
grounded=ai_result["grounded"],
|
| 247 |
+
sources=sources,
|
| 248 |
+
retrieved_chunks=len(results)
|
| 249 |
+
)
|
app/schemas.py
CHANGED
|
@@ -87,4 +87,22 @@ class DocumentResponse(BaseModel):
|
|
| 87 |
error_message: str | None
|
| 88 |
chunk_count: int = 0
|
| 89 |
created_at: datetime
|
| 90 |
-
updated_at: datetime
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
error_message: str | None
|
| 88 |
chunk_count: int = 0
|
| 89 |
created_at: datetime
|
| 90 |
+
updated_at: datetime
|
| 91 |
+
class QueryRequest(BaseModel):
|
| 92 |
+
model_config = ConfigDict(extra="forbid")
|
| 93 |
+
question: str = Field(min_length=3, max_length=1000)
|
| 94 |
+
|
| 95 |
+
class QuerySource(BaseModel):
|
| 96 |
+
document_id: UUID
|
| 97 |
+
filename: str
|
| 98 |
+
chunk_index: int
|
| 99 |
+
|
| 100 |
+
class QueryResponse(BaseModel):
|
| 101 |
+
hub_id: UUID
|
| 102 |
+
question: str
|
| 103 |
+
answer: str
|
| 104 |
+
evidence: str | None
|
| 105 |
+
model: str
|
| 106 |
+
grounded: bool
|
| 107 |
+
sources: list[QuerySource] = []
|
| 108 |
+
retrieved_chunks: int = 0
|
app/services/gemini.py
CHANGED
|
@@ -14,7 +14,7 @@ class AiUnavailable(Exception):
|
|
| 14 |
|
| 15 |
_client = genai.Client(
|
| 16 |
api_key=settings.GEMINI_API_KEY,
|
| 17 |
-
http_options=types.HttpOptions(timeout=
|
| 18 |
)
|
| 19 |
|
| 20 |
INSIGHTS_SYSTEM = (
|
|
@@ -37,7 +37,7 @@ def _classify(exc: Exception) -> str:
|
|
| 37 |
return "timeout"
|
| 38 |
return "provider"
|
| 39 |
|
| 40 |
-
def _call(system: str, user: str, retries: int =
|
| 41 |
for attempt in range(retries + 1):
|
| 42 |
try:
|
| 43 |
r = _client.models.generate_content(
|
|
@@ -50,15 +50,22 @@ def _call(system: str, user: str, retries: int = 0) -> dict:
|
|
| 50 |
response_mime_type="application/json"
|
| 51 |
)
|
| 52 |
)
|
| 53 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
except Exception as exc:
|
| 55 |
kind = _classify(exc)
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
time.sleep(0.5)
|
| 59 |
continue
|
| 60 |
raise AiUnavailable(kind, str(exc)) from exc
|
| 61 |
-
|
| 62 |
def hub_insights(content: str) -> tuple[str | None, list[str]]:
|
| 63 |
"""Write path: never raises, never retries (at most ONE Gemini call per created hub)."""
|
| 64 |
try:
|
|
|
|
| 14 |
|
| 15 |
_client = genai.Client(
|
| 16 |
api_key=settings.GEMINI_API_KEY,
|
| 17 |
+
http_options=types.HttpOptions(timeout=45_000) # 15 seconds in milliseconds
|
| 18 |
)
|
| 19 |
|
| 20 |
INSIGHTS_SYSTEM = (
|
|
|
|
| 37 |
return "timeout"
|
| 38 |
return "provider"
|
| 39 |
|
| 40 |
+
def _call(system: str, user: str, retries: int = 3) -> dict:
|
| 41 |
for attempt in range(retries + 1):
|
| 42 |
try:
|
| 43 |
r = _client.models.generate_content(
|
|
|
|
| 50 |
response_mime_type="application/json"
|
| 51 |
)
|
| 52 |
)
|
| 53 |
+
|
| 54 |
+
# FIX: Sometimes the model returns markdown code blocks. Strip them.
|
| 55 |
+
raw_text = r.text.strip()
|
| 56 |
+
if raw_text.startswith("```json"):
|
| 57 |
+
raw_text = raw_text[7:]
|
| 58 |
+
if raw_text.endswith("```"):
|
| 59 |
+
raw_text = raw_text[:-3]
|
| 60 |
+
|
| 61 |
+
return json.loads(raw_text.strip())
|
| 62 |
except Exception as exc:
|
| 63 |
kind = _classify(exc)
|
| 64 |
+
if kind in ("provider", "timeout") and attempt < retries:
|
| 65 |
+
time.sleep(1)
|
|
|
|
| 66 |
continue
|
| 67 |
raise AiUnavailable(kind, str(exc)) from exc
|
| 68 |
+
|
| 69 |
def hub_insights(content: str) -> tuple[str | None, list[str]]:
|
| 70 |
"""Write path: never raises, never retries (at most ONE Gemini call per created hub)."""
|
| 71 |
try:
|
tests/test_rag.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
from uuid import uuid4
|
| 3 |
+
from unittest.mock import patch
|
| 4 |
+
from app.models import Hub, Document, Chunk
|
| 5 |
+
|
| 6 |
+
def test_empty_retrieval_returns_ungrounded(client, db_session):
|
| 7 |
+
client.post("/api/v1/auth/signup", json={"email": "rag@test.com", "password": "Password123"})
|
| 8 |
+
token = client.post("/api/v1/auth/login", json={"email": "rag@test.com", "password": "Password123"}).json()["access_token"]
|
| 9 |
+
headers = {"Authorization": f"Bearer {token}"}
|
| 10 |
+
|
| 11 |
+
hub_res = client.post("/api/v1/hubs", headers={**headers, "X-Idempotency-Key": str(uuid4())}, json={"name": "Empty Hub"})
|
| 12 |
+
hub_id = hub_res.json()["id"]
|
| 13 |
+
|
| 14 |
+
# Query without any documents/chunks
|
| 15 |
+
with patch("app.routers.hubs.embed_texts", return_value=[[0.1]*768]), \
|
| 16 |
+
patch("app.routers.hubs.answer_question") as mock_answer:
|
| 17 |
+
|
| 18 |
+
res = client.post(f"/api/v1/hubs/{hub_id}/query", headers=headers, json={"question": "What is the policy?"})
|
| 19 |
+
|
| 20 |
+
assert res.status_code == 200
|
| 21 |
+
assert res.json()["grounded"] is False
|
| 22 |
+
assert res.json()["evidence"] is None
|
| 23 |
+
assert res.json()["sources"] == []
|
| 24 |
+
# CRITICAL: Gemini was never called
|
| 25 |
+
mock_answer.assert_not_called()
|
| 26 |
+
|
| 27 |
+
def test_vector_search_is_scoped_to_owner(client, db_session):
|
| 28 |
+
# Create User A and User B
|
| 29 |
+
client.post("/api/v1/auth/signup", json={"email": "usera@rag.com", "password": "Password123"})
|
| 30 |
+
token_a = client.post("/api/v1/auth/login", json={"email": "usera@rag.com", "password": "Password123"}).json()["access_token"]
|
| 31 |
+
|
| 32 |
+
client.post("/api/v1/auth/signup", json={"email": "userb@rag.com", "password": "Password123"})
|
| 33 |
+
token_b = client.post("/api/v1/auth/login", json={"email": "userb@rag.com", "password": "Password123"}).json()["access_token"]
|
| 34 |
+
|
| 35 |
+
# User A creates a hub and a document (we mock the ingestion for speed)
|
| 36 |
+
hub_a = client.post("/api/v1/hubs", headers={"Authorization": f"Bearer {token_a}", "X-Idempotency-Key": str(uuid4())}, json={"name": "A's Secret Hub"}).json()
|
| 37 |
+
|
| 38 |
+
# Insert a chunk manually
|
| 39 |
+
from app.models import User, Hub
|
| 40 |
+
user_a = db_session.query(User).filter(User.email == "usera@rag.com").first()
|
| 41 |
+
hub = db_session.query(Hub).filter(Hub.id == hub_a["id"]).first()
|
| 42 |
+
|
| 43 |
+
doc = Document(id=uuid4(), hub_id=hub.id, owner_id=user_a.id, origin="upload", filename="a.txt", mime_type="text/plain", size_bytes=10, checksum_sha256="0"*64, status="ready")
|
| 44 |
+
db_session.add(doc)
|
| 45 |
+
db_session.commit()
|
| 46 |
+
|
| 47 |
+
chunk = Chunk(id=uuid4(), document_id=doc.id, hub_id=hub.id, owner_id=user_a.id, chunk_index=0, text="The secret is 42.", token_count=5, embedding=[0.1]*768)
|
| 48 |
+
db_session.add(chunk)
|
| 49 |
+
db_session.commit()
|
| 50 |
+
|
| 51 |
+
# User B tries to query User A's hub
|
| 52 |
+
with patch("app.routers.hubs.embed_texts", return_value=[[0.1]*768]):
|
| 53 |
+
res = client.post(f"/api/v1/hubs/{hub.id}/query", headers={"Authorization": f"Bearer {token_b}"}, json={"question": "What is the secret?"})
|
| 54 |
+
assert res.status_code == 404
|
| 55 |
+
assert res.json()["error"]["code"] == "HUB_NOT_FOUND"
|