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

feat(query): multi-query expansion for improved retrieval (#13)

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

* feat(ai): add expand_query for multi-query retrieval

* feat(repo): add multi-query union retrieval

* feat(query): integrate multi-query expansion into rag

* test(query): cover multihop sources and expansion fallback

Files changed (4) hide show
  1. app/repo.py +21 -5
  2. app/routers/hubs.py +14 -54
  3. app/services/gemini.py +14 -1
  4. tests/test_rag.py +41 -7
app/repo.py CHANGED
@@ -1,7 +1,6 @@
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):
@@ -14,15 +13,32 @@ def get_hub_for_owner(db: Session, hub_id: UUID, owner_id: UUID) -> Hub | None:
14
  return db.query(Hub).filter(Hub.id == hub_id, Hub.owner_id == owner_id).first()
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
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from uuid import UUID
2
  from sqlalchemy.orm import Session
3
+ from app.models import Hub, Chunk
 
4
  from app.config import settings
5
 
6
  def list_hubs_for_owner(db: Session, owner_id: UUID, limit: int, offset: int):
 
13
  return db.query(Hub).filter(Hub.id == hub_id, Hub.owner_id == owner_id).first()
14
 
15
  def delete_hub_for_owner(db: Session, hub_id: UUID, owner_id: UUID) -> int:
 
16
  return db.query(Hub).filter(Hub.id == hub_id, Hub.owner_id == owner_id).delete()
17
 
18
  def retrieve_chunks_for_query(db: Session, owner_id: UUID, hub_id: UUID, query_embedding: list[float]):
 
19
  return (
20
  db.query(Chunk)
21
  .filter(Chunk.owner_id == owner_id, Chunk.hub_id == hub_id)
22
  .order_by(Chunk.embedding.cosine_distance(query_embedding))
23
  .limit(settings.RAG_TOP_K)
24
  .all()
25
+ )
26
+
27
+ def retrieve_chunks_for_queries(db: Session, owner_id: UUID, hub_id: UUID, query_embeddings: list[list[float]]):
28
+ chunk_ids = set()
29
+ chunks = []
30
+
31
+ for emb in query_embeddings:
32
+ results = (
33
+ db.query(Chunk)
34
+ .filter(Chunk.owner_id == owner_id, Chunk.hub_id == hub_id)
35
+ .order_by(Chunk.embedding.cosine_distance(emb))
36
+ .limit(settings.RAG_TOP_K)
37
+ .all()
38
+ )
39
+ for r in results:
40
+ if r.id not in chunk_ids:
41
+ chunk_ids.add(r.id)
42
+ chunks.append(r)
43
+
44
+ return chunks
app/routers/hubs.py CHANGED
@@ -2,10 +2,12 @@ from uuid import UUID
2
  from fastapi import APIRouter, Depends, status, Query, Response
3
  from sqlalchemy.orm import Session
4
  from sqlalchemy.exc import IntegrityError
 
 
5
  from app.db import get_db
6
  from app.deps import get_current_user
7
  from app.models import User, Hub, Document, Chunk
8
- from app.schemas import HubResponse, HubDetail, HubCreate
9
  from app.errors import AppError
10
  from app import repo
11
  from app.idempotency import (
@@ -14,12 +16,9 @@ from app.idempotency import (
14
  )
15
  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
- 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
 
@@ -80,21 +79,15 @@ def create_hub(
80
  if not claim_key(db, user_id, key, req_hash, "/api/v1/hubs"):
81
  return replay_or_conflict(db, user_id, key, req_hash)
82
 
83
- # 1. Pre-check (Fast fail to save AI tokens)
84
  if db.query(Hub).filter(Hub.owner_id == user_id, Hub.name == body.name).first():
85
  release_claim(db, user_id, key)
86
  raise AppError(409, "HUB_NAME_CONFLICT", "You already have a hub with this name.")
87
 
88
- # 2. AI Summary (Wrapped in observability)
89
  if body.content:
90
- summary, questions = log_ai_call(
91
- "hub_insights", user_id, None,
92
- hub_insights, body.content
93
- )
94
  else:
95
  summary, questions = None, []
96
 
97
- # 3. Create Hub
98
  new_hub = Hub(
99
  owner_id=user_id, name=body.name, description=body.description,
100
  ai_summary=summary, suggested_questions=questions
@@ -102,7 +95,6 @@ def create_hub(
102
  db.add(new_hub)
103
 
104
  try:
105
- # Flush assigns the ID and triggers the UNIQUE constraint check
106
  db.flush()
107
 
108
  doc_count = 0
@@ -115,17 +107,12 @@ def create_hub(
115
  mime_type="text/plain", size_bytes=len(body.content), checksum_sha256="0"*64, status="pending"
116
  )
117
  db.add(new_doc)
118
- db.flush() # Get doc ID
119
 
120
  try:
121
  chunks_data = chunk_text(body.content)
122
  texts = [c[0] for c in chunks_data]
123
-
124
- # Embeddings (Wrapped in observability)
125
- embeddings = log_ai_call(
126
- "embed_texts", user_id, new_hub.id,
127
- embed_texts, texts
128
- )
129
 
130
  for i, ((text, tokens), emb) in enumerate(zip(chunks_data, embeddings)):
131
  db.add(Chunk(
@@ -135,7 +122,6 @@ def create_hub(
135
  chunk_count += 1
136
  new_doc.status = "ready"
137
  except Exception:
138
- # Graceful degradation: AI failed, but we keep the hub
139
  new_doc.status = "failed"
140
  new_doc.error_message = "Embedding failed"
141
 
@@ -153,11 +139,8 @@ def create_hub(
153
  except IntegrityError as e:
154
  db.rollback()
155
  release_claim(db, user_id, key)
156
-
157
- # Check if the error was our specific unique constraint
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)
@@ -167,31 +150,15 @@ def query_hub(
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
@@ -204,10 +171,9 @@ def query_hub(
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:
@@ -216,21 +182,15 @@ def query_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(
 
2
  from fastapi import APIRouter, Depends, status, Query, Response
3
  from sqlalchemy.orm import Session
4
  from sqlalchemy.exc import IntegrityError
5
+ from sqlalchemy import text
6
+
7
  from app.db import get_db
8
  from app.deps import get_current_user
9
  from app.models import User, Hub, Document, Chunk
10
+ from app.schemas import HubResponse, HubDetail, HubCreate, QueryRequest, QueryResponse, QuerySource
11
  from app.errors import AppError
12
  from app import repo
13
  from app.idempotency import (
 
16
  )
17
  from app.chunking import chunk_text
18
  from app.services.embeddings import embed_texts
19
+ from app.services.gemini import hub_insights, answer_question, expand_query
20
  from app.services.observability import log_ai_call
21
+ from app.config import settings
 
 
 
22
 
23
  router = APIRouter(prefix="/hubs", tags=["hubs"])
24
 
 
79
  if not claim_key(db, user_id, key, req_hash, "/api/v1/hubs"):
80
  return replay_or_conflict(db, user_id, key, req_hash)
81
 
 
82
  if db.query(Hub).filter(Hub.owner_id == user_id, Hub.name == body.name).first():
83
  release_claim(db, user_id, key)
84
  raise AppError(409, "HUB_NAME_CONFLICT", "You already have a hub with this name.")
85
 
 
86
  if body.content:
87
+ summary, questions = log_ai_call("hub_insights", user_id, None, hub_insights, body.content)
 
 
 
88
  else:
89
  summary, questions = None, []
90
 
 
91
  new_hub = Hub(
92
  owner_id=user_id, name=body.name, description=body.description,
93
  ai_summary=summary, suggested_questions=questions
 
95
  db.add(new_hub)
96
 
97
  try:
 
98
  db.flush()
99
 
100
  doc_count = 0
 
107
  mime_type="text/plain", size_bytes=len(body.content), checksum_sha256="0"*64, status="pending"
108
  )
109
  db.add(new_doc)
110
+ db.flush()
111
 
112
  try:
113
  chunks_data = chunk_text(body.content)
114
  texts = [c[0] for c in chunks_data]
115
+ embeddings = log_ai_call("embed_texts", user_id, new_hub.id, embed_texts, texts)
 
 
 
 
 
116
 
117
  for i, ((text, tokens), emb) in enumerate(zip(chunks_data, embeddings)):
118
  db.add(Chunk(
 
122
  chunk_count += 1
123
  new_doc.status = "ready"
124
  except Exception:
 
125
  new_doc.status = "failed"
126
  new_doc.error_message = "Embedding failed"
127
 
 
139
  except IntegrityError as e:
140
  db.rollback()
141
  release_claim(db, user_id, key)
 
 
142
  if getattr(e.orig.diag, "constraint_name", None) == "uq_hub_name_per_owner":
143
  raise AppError(409, "HUB_NAME_CONFLICT", "You already have a hub with this name.")
 
144
  raise
145
 
146
  @router.post("/{hub_id}/query", response_model=QueryResponse)
 
150
  current_user: User = Depends(get_current_user),
151
  db: Session = Depends(get_db)
152
  ):
 
153
  hub = repo.get_hub_for_owner(db, hub_id, current_user.id)
154
  if not hub:
155
  raise AppError(404, "HUB_NOT_FOUND", "Hub not found.")
156
 
157
+ alt_queries = expand_query(req.question)
158
+ all_queries = [req.question] + alt_queries
159
+ embeddings = embed_texts(all_queries)
160
+
161
+ chunks = repo.retrieve_chunks_for_queries(db, current_user.id, hub_id, embeddings)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
162
 
163
  raw_sql = text("""
164
  SELECT id, text, chunk_index, document_id, 1 - (embedding <=> :q) as similarity
 
171
 
172
  results = db.execute(
173
  raw_sql,
174
+ {"q": str(embeddings[0]), "u": current_user.id, "h": hub_id, "threshold": settings.RAG_MIN_SIMILARITY, "k": settings.RAG_TOP_K}
175
  ).fetchall()
176
 
 
177
  db.rollback()
178
 
179
  if not results:
 
182
  evidence=None, model=settings.GEMINI_MODEL, grounded=False, sources=[], retrieved_chunks=0
183
  )
184
 
 
185
  context_texts = [row.text for row in results]
186
  context = "\n\n---\n\n".join(context_texts)
187
 
 
188
  ai_result = answer_question(context, req.question)
189
 
 
190
  sources = []
191
  if ai_result["evidence"]:
192
  for row in results:
193
  if ai_result["evidence"].lower() in row.text.lower():
 
 
 
194
  doc = db.query(Document).filter(Document.id == row.document_id).first()
195
  if doc:
196
  sources.append(QuerySource(
app/services/gemini.py CHANGED
@@ -84,4 +84,17 @@ def answer_question(content: str, question: str) -> dict:
84
  "answer": str(d.get("answer", "")).strip(),
85
  "evidence": evidence if verified else None,
86
  "grounded": bool(d.get("grounded")) and verified
87
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
  "answer": str(d.get("answer", "")).strip(),
85
  "evidence": evidence if verified else None,
86
  "grounded": bool(d.get("grounded")) and verified
87
+ }
88
+ EXPAND_SYSTEM = (
89
+ "You are a search assistant. Generate exactly 2 alternative versions of the user's question "
90
+ "to improve vector search retrieval. Reply with JSON only: "
91
+ '{"queries": ["alternative 1", "alternative 2"]}'
92
+ )
93
+
94
+ def expand_query(question: str) -> list[str]:
95
+ """Returns up to 2 alternative phrasings of the question. Fails gracefully."""
96
+ try:
97
+ d = _call(EXPAND_SYSTEM, question, retries=0)
98
+ return [str(q) for q in d.get("queries", [])][:2]
99
+ except Exception:
100
+ return [] # Fallback to just the original question
tests/test_rag.py CHANGED
@@ -11,7 +11,6 @@ def test_empty_retrieval_returns_ungrounded(client, db_session):
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
 
@@ -21,21 +20,17 @@ def test_empty_retrieval_returns_ungrounded(client, db_session):
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()
@@ -48,8 +43,47 @@ def test_vector_search_is_scoped_to_owner(client, db_session):
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"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
  with patch("app.routers.hubs.embed_texts", return_value=[[0.1]*768]), \
15
  patch("app.routers.hubs.answer_question") as mock_answer:
16
 
 
20
  assert res.json()["grounded"] is False
21
  assert res.json()["evidence"] is None
22
  assert res.json()["sources"] == []
 
23
  mock_answer.assert_not_called()
24
 
25
  def test_vector_search_is_scoped_to_owner(client, db_session):
 
26
  client.post("/api/v1/auth/signup", json={"email": "usera@rag.com", "password": "Password123"})
27
  token_a = client.post("/api/v1/auth/login", json={"email": "usera@rag.com", "password": "Password123"}).json()["access_token"]
28
 
29
  client.post("/api/v1/auth/signup", json={"email": "userb@rag.com", "password": "Password123"})
30
  token_b = client.post("/api/v1/auth/login", json={"email": "userb@rag.com", "password": "Password123"}).json()["access_token"]
31
 
 
32
  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()
33
 
 
34
  from app.models import User, Hub
35
  user_a = db_session.query(User).filter(User.email == "usera@rag.com").first()
36
  hub = db_session.query(Hub).filter(Hub.id == hub_a["id"]).first()
 
43
  db_session.add(chunk)
44
  db_session.commit()
45
 
 
46
  with patch("app.routers.hubs.embed_texts", return_value=[[0.1]*768]):
47
  res = client.post(f"/api/v1/hubs/{hub.id}/query", headers={"Authorization": f"Bearer {token_b}"}, json={"question": "What is the secret?"})
48
  assert res.status_code == 404
49
+ assert res.json()["error"]["code"] == "HUB_NOT_FOUND"
50
+
51
+ def test_expansion_fallback_when_gemini_fails(client, db_session):
52
+ client.post("/api/v1/auth/signup", json={"email": "fallback@test.com", "password": "Password123"})
53
+ token = client.post("/api/v1/auth/login", json={"email": "fallback@test.com", "password": "Password123"}).json()["access_token"]
54
+ headers = {"Authorization": f"Bearer {token}"}
55
+ hub_res = client.post("/api/v1/hubs", headers={**headers, "X-Idempotency-Key": str(uuid4())}, json={"name": "Fallback Hub"})
56
+ hub_id = hub_res.json()["id"]
57
+
58
+ with patch("app.services.gemini._call", side_effect=Exception("AI Down")), \
59
+ patch("app.routers.hubs.embed_texts", return_value=[[0.1]*768]), \
60
+ patch("app.routers.hubs.answer_question", return_value={"answer": "Test", "evidence": None, "grounded": False}):
61
+
62
+ res = client.post(f"/api/v1/hubs/{hub_id}/query", headers=headers, json={"question": "Test question?"})
63
+ assert res.status_code == 200
64
+
65
+ def test_multihop_question_returns_two_sources(client, db_session):
66
+ client.post("/api/v1/auth/signup", json={"email": "multihop@test.com", "password": "Password123"})
67
+ token = client.post("/api/v1/auth/login", json={"email": "multihop@test.com", "password": "Password123"}).json()["access_token"]
68
+ headers = {"Authorization": f"Bearer {token}"}
69
+ hub_res = client.post("/api/v1/hubs", headers={**headers, "X-Idempotency-Key": str(uuid4())}, json={"name": "MultiHop Hub"})
70
+ hub_id = hub_res.json()["id"]
71
+
72
+ from app.models import User, Hub, Document, Chunk
73
+ user = db_session.query(User).filter(User.email == "multihop@test.com").first()
74
+ hub = db_session.query(Hub).filter(Hub.id == hub_id).first()
75
+ doc = Document(id=uuid4(), hub_id=hub.id, owner_id=user.id, origin="upload", filename="test.txt", mime_type="text/plain", size_bytes=10, checksum_sha256="0"*64, status="ready")
76
+ db_session.add(doc)
77
+ db_session.commit()
78
+
79
+ chunk1 = Chunk(id=uuid4(), document_id=doc.id, hub_id=hub.id, owner_id=user.id, chunk_index=0, text="Chunk one", token_count=5, embedding=[0.1]*768)
80
+ chunk2 = Chunk(id=uuid4(), document_id=doc.id, hub_id=hub.id, owner_id=user.id, chunk_index=1, text="Chunk two", token_count=5, embedding=[0.2]*768)
81
+ db_session.add_all([chunk1, chunk2])
82
+ db_session.commit()
83
+
84
+ with patch("app.routers.hubs.expand_query", return_value=["alt1", "alt2"]), \
85
+ patch("app.routers.hubs.embed_texts", return_value=[[0.1]*768, [0.1]*768, [0.2]*768]), \
86
+ patch("app.routers.hubs.answer_question", return_value={"answer": "Combined", "evidence": "Chunk", "grounded": True}):
87
+
88
+ res = client.post(f"/api/v1/hubs/{hub_id}/query", headers=headers, json={"question": "Question spanning two chunks?"})
89
+ assert res.status_code == 200