@@ -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
77112def 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