Skip to content

Commit 4149e40

Browse files
committed
feat: implement semantic sub-query decomposition and parallel retrieval with multiple RRF merge
1 parent bf9d1db commit 4149e40

1 file changed

Lines changed: 63 additions & 10 deletions

File tree

backend/app/rag/retriever.py

Lines changed: 63 additions & 10 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]:
@@ -98,15 +133,18 @@ def _generate_query_variants(query: str) -> List[str]:
98133

99134
client = InferenceClient(token=settings.HF_TOKEN)
100135
prompt = (
101-
"Rewrite the user question into concise semantic search queries for document retrieval. "
102-
"Split independent topics into separate queries. Return a JSON array of strings only. "
136+
"Decompose the user's complex multi-part question into simple, distinct semantic sub-queries. "
137+
"Each sub-query should focus on a single question, topic, or comparison. "
138+
"Return a JSON array of strings only. "
139+
"Example question: 'Compare treatment A and treatment B for diabetes'\n"
140+
"Example output: [\"treatment A for diabetes\", \"treatment B for diabetes\", \"diabetes treatments comparison\"]\n"
103141
f"User question: {query}"
104142
)
105143
response = client.chat_completion(
106144
messages=[
107145
{
108146
"role": "system",
109-
"content": "You create optimized search queries for a RAG retriever.",
147+
"content": "You decompose complex search queries into list of search sub-queries for a RAG retriever.",
110148
},
111149
{"role": "user", "content": prompt},
112150
],
@@ -221,10 +259,11 @@ def retrieve(
221259
"""
222260
effective_top_k = top_k if top_k is not None else settings.TOP_K_RETRIEVAL
223261

224-
# ── Stage 1: Hybrid retrieval with query transformation ───────────────────
225-
all_candidates: List[Dict[str, Any]] = []
262+
# ── Stage 1: Parallel retrieval of sub-queries and RRF merging ───────────
263+
sub_queries = transform_query(query)
264+
sub_query_results: List[List[Dict[str, Any]]] = []
226265

227-
for search_query in transform_query(query):
266+
def retrieve_single_query(search_query: str) -> List[Dict[str, Any]]:
228267
query_vector = embed_query(search_query)
229268

230269
# Vector results (always)
@@ -257,14 +296,28 @@ def retrieve(
257296
for chunk in merged:
258297
chunk["score"] = chunk.pop("rrf_score")
259298

260-
all_candidates.extend(merged)
299+
return merged
261300
else:
262-
all_candidates.extend(vector_results)
301+
return vector_results
302+
303+
import concurrent.futures
304+
with concurrent.futures.ThreadPoolExecutor(max_workers=len(sub_queries) or 1) as executor:
305+
future_to_query = {executor.submit(retrieve_single_query, sq): sq for sq in sub_queries}
306+
for future in concurrent.futures.as_completed(future_to_query):
307+
try:
308+
results = future.result()
309+
sub_query_results.append(results)
310+
except Exception as e:
311+
sq = future_to_query[future]
312+
logger.error("Failed retrieval for sub-query '%s': %s", sq, e)
263313

264-
if not all_candidates:
314+
if not sub_query_results:
265315
return []
266316

267-
candidates = _merge_candidates(all_candidates)
317+
# Merge all sub-query candidate lists using generalized RRF
318+
candidates = rrf_merge_multiple(sub_query_results, k=settings.RRF_K)
319+
for chunk in candidates:
320+
chunk["score"] = chunk.pop("rrf_score")
268321

269322
# ── Stage 2: Cross-encoder reranking ─────────────────────────────────────
270323
reranker = get_reranker()

0 commit comments

Comments
 (0)