RamshaAnwar commited on
Commit
df79db8
·
unverified ·
1 Parent(s): cc78da3

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 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=15_000) # 15 seconds in milliseconds
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 = 0) -> dict:
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
- return json.loads(r.text)
 
 
 
 
 
 
 
 
54
  except Exception as exc:
55
  kind = _classify(exc)
56
- # Retry only provider errors (like 500s). Never retry quota or timeouts.
57
- if kind == "provider" and attempt < retries:
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"