Skip to content

Commit 6d76216

Browse files
authored
Merge PR #587
feat: implement semantic sub-query decomposition and parallel retrieval with RRF merge
2 parents 39c18a5 + b76e4ad commit 6d76216

1 file changed

Lines changed: 64 additions & 18 deletions

File tree

backend/app/rag/retriever.py

Lines changed: 64 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,41 @@ def _accumulate(results: List[Dict[str, Any]]) -> None:
7272
return merged
7373

7474

75+
def rrf_merge_multiple(
76+
ranked_lists: List[List[Dict[str, Any]]],
77+
k: int = 60,
78+
) -> List[Dict[str, Any]]:
79+
"""Merge multiple ranked lists using Reciprocal Rank Fusion."""
80+
rrf_scores: Dict[str, float] = {}
81+
chunk_store: Dict[str, Dict[str, Any]] = {}
82+
83+
def _key(chunk: Dict[str, Any]) -> str:
84+
for field in ("id", "chunk_id"):
85+
if chunk.get(field):
86+
return str(chunk[field])
87+
text = str(chunk.get("text", ""))
88+
return "|".join([
89+
str(chunk.get("document_id", "")),
90+
str(chunk.get("page", "")),
91+
text[:200],
92+
])
93+
94+
for lst in ranked_lists:
95+
for rank, chunk in enumerate(lst, start=1):
96+
key = _key(chunk)
97+
rrf_scores[key] = rrf_scores.get(key, 0.0) + 1.0 / (k + rank)
98+
if key not in chunk_store or chunk.get("score", 0) > chunk_store[key].get("score", 0):
99+
chunk_store[key] = chunk
100+
101+
merged = []
102+
for key, rrf_score in sorted(rrf_scores.items(), key=lambda t: t[1], reverse=True):
103+
chunk = chunk_store[key].copy()
104+
chunk["rrf_score"] = round(rrf_score, 6)
105+
merged.append(chunk)
106+
107+
return merged
108+
109+
75110
# ── Query helpers ─────────────────────────────────────────────────────────────
76111

77112
def transform_query(query: str) -> List[str]:
@@ -105,23 +140,19 @@ def _generate_query_variants(query: str) -> List[str]:
105140
client = InferenceClient(token=settings.HF_TOKEN)
106141

107142
prompt = (
108-
"Generate exactly 3 semantic variations of the user question below. "
109-
"Each variation must preserve the original meaning but use different "
110-
"vocabulary, phrasing, or sentence structure to improve document retrieval coverage. "
111-
"Do NOT add new topics or change the intent. "
112-
"Return ONLY a JSON array of 3 strings, with no extra text, markdown, or explanation.\n\n"
113-
f"User question: {query}\n\n"
114-
'Example output: ["variation one", "variation two", "variation three"]'
143+
"Decompose the user's complex multi-part question into simple, distinct semantic sub-queries. "
144+
"Each sub-query should focus on a single question, topic, or comparison. "
145+
"Return a JSON array of strings only. "
146+
"Example question: 'Compare treatment A and treatment B for diabetes'\n"
147+
"Example output: [\"treatment A for diabetes\", \"treatment B for diabetes\", \"diabetes treatments comparison\"]\n"
148+
f"User question: {query}"
115149
)
116150

117151
response = client.chat_completion(
118152
messages=[
119153
{
120154
"role": "system",
121-
"content": (
122-
"You are a query rewriter for a RAG retrieval system. "
123-
"You output only valid JSON arrays of strings, nothing else."
124-
),
155+
"content": "You decompose complex search queries into list of search sub-queries for a RAG retriever.",
125156
},
126157
{"role": "user", "content": prompt},
127158
],
@@ -240,10 +271,11 @@ def retrieve(
240271
"""
241272
effective_top_k = top_k if top_k is not None else settings.TOP_K_RETRIEVAL
242273

243-
# ── Stage 1: Hybrid retrieval with query transformation ───────────────────
244-
all_candidates: List[Dict[str, Any]] = []
274+
# ── Stage 1: Parallel retrieval of sub-queries and RRF merging ───────────
275+
sub_queries = transform_query(query)
276+
sub_query_results: List[List[Dict[str, Any]]] = []
245277

246-
for search_query in transform_query(query):
278+
def retrieve_single_query(search_query: str) -> List[Dict[str, Any]]:
247279
query_vector = embed_query(search_query)
248280

249281
# Vector results (always)
@@ -278,14 +310,28 @@ def retrieve(
278310
for chunk in merged:
279311
chunk["score"] = chunk.pop("rrf_score")
280312

281-
all_candidates.extend(merged)
313+
return merged
282314
else:
283-
all_candidates.extend(vector_results)
315+
return vector_results
316+
317+
import concurrent.futures
318+
with concurrent.futures.ThreadPoolExecutor(max_workers=len(sub_queries) or 1) as executor:
319+
future_to_query = {executor.submit(retrieve_single_query, sq): sq for sq in sub_queries}
320+
for future in concurrent.futures.as_completed(future_to_query):
321+
try:
322+
results = future.result()
323+
sub_query_results.append(results)
324+
except Exception as e:
325+
sq = future_to_query[future]
326+
logger.error("Failed retrieval for sub-query '%s': %s", sq, e)
284327

285-
if not all_candidates:
328+
if not sub_query_results:
286329
return []
287330

288-
candidates = _merge_candidates(all_candidates)
331+
# Merge all sub-query candidate lists using generalized RRF
332+
candidates = rrf_merge_multiple(sub_query_results, k=settings.RRF_K)
333+
for chunk in candidates:
334+
chunk["score"] = chunk.pop("rrf_score")
289335

290336
# ── Stage 2: Cross-encoder reranking ─────────────────────────────────────
291337
reranker = get_reranker()

0 commit comments

Comments
 (0)