Skip to content

feat: implement query decomposition for search and enhance reranking … - #266

Merged
EnjoyBacon7 merged 3 commits into
devfrom
feat/query_decomposition_for_search
Mar 12, 2026
Merged

feat: implement query decomposition for search and enhance reranking …#266
EnjoyBacon7 merged 3 commits into
devfrom
feat/query_decomposition_for_search

Conversation

@Ahmath-Gadji

@Ahmath-Gadji Ahmath-Gadji commented Mar 4, 2026

Copy link
Copy Markdown
Collaborator

This PR Improves retrieval quality and reranking by adding multi-query generation and RRF-based result fusion.

  • Added SearchQueries model to generate multiple sub-queries from chat history when needed to handle advances queries that requires decomposition.

    • Query like "difference between US, China and EU approach concerning personal data governance" produces subqueries
      • Query 1: What is the approach of the United States concerning personal data governance?
      • Query 2: What is the approach of China concerning personal data governance?
      • Query 3: What is the approach of the European Union concerning personal data governance?
  • Updated pipeline to retrieve with multiple queries and merge results using Reciprocal Rank Fusion (RRF).

  • Added tests for RRF logic.

Summary by CodeRabbit

  • New Features

    • Multi-query decomposition: breaks complex questions into multiple autonomous search queries.
    • Batch retrieval & aggregation: fetches and deduplicates web results across sub-queries and joins them for map-reduce.
    • New reranking fusion: Reciprocal Rank Fusion improves aggregated ranking and supports temporal reranking.
  • Documentation

    • Expanded query-generation guidelines with decomposition rules and contextual enrichment examples.
  • Tests

    • Added unit tests for RRF reranking and mock tool-call responses for chat completion.
  • Bug Fixes

    • Minor error-message formatting tweak in messaging path.

@coderabbitai

coderabbitai Bot commented Mar 4, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Introduces multi-query support via a new SearchQueries model, replaces AsyncOpenAI with ChatOpenAI plus structured function output for query generation, adds batch retrieval (get_relevant_docs), adds RRF reranking and tests, updates prompt template for query decomposition, and extends mock vLLM to simulate tool calls. Error message formatting in on_message adjusted.

Changes

Cohort / File(s) Summary
Query Pipeline Core
openrag/components/pipeline.py, prompts/example1/query_contextualizer_tmpl.txt
Adds SearchQueries model and switches query generation to ChatOpenAI with structured output; generate_query now returns SearchQueries. Adds batch retrieval via RetrieverPipeline.get_relevant_docs(), propagates SearchQueries through _prepare_for_chat_completion/_prepare_for_completions, changes map-reduce input to joined multi-query string, and enhances prompt template for multi-query decomposition and language/date injection.
Reranking System & Tests
openrag/components/reranker.py, openrag/components/test_rrf_reranking.py
Introduces BaseReranker with static rrf_reranking() (Reciprocal Rank Fusion). Reranker now inherits from BaseReranker, adds temporal_reranking flag, preserves semaphore usage. Adds comprehensive unit tests validating RRF behavior, deduplication, ranking, and metadata preservation.
Mock API Infrastructure
tests/api_tests/api_run/mock_vllm.py
Extends ChatCompletionRequest with tools and tool_choice fields and adds generate_tool_call_response() to build mock tool-call structured responses; create_chat_completion routes tool-equipped requests to the new generator.
Error Handling
openrag/app_front.py
Simplified exception formatting in on_message by using {e} instead of {e!s} in the emitted error string.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant App as App Frontend
    participant QueryGen as QueryGenerator<br/>(ChatOpenAI)
    participant Retriever as RetrieverPipeline
    participant Reranker as Reranker (RRF)
    participant Storage as Document Store

    User->>App: Send message
    App->>QueryGen: call generate_query(messages)
    QueryGen->>QueryGen: detect_language + inject current date
    QueryGen-->>App: return SearchQueries (multiple queries)
    App->>Retriever: get_relevant_docs(partition, SearchQueries, top_k)

    loop per sub-query
        Retriever->>Storage: retrieve documents for sub-query
        Storage-->>Retriever: documents
    end

    Retriever->>Reranker: rrf_reranking(doc_lists)
    Reranker->>Reranker: fuse rankings, deduplicate
    Reranker-->>Retriever: reranked documents
    Retriever-->>App: aggregated ranked documents
    App-->>User: return results
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

  • Feat/add ruff linting #214 — touches same error-message formatting in openrag/app_front.py (change between {e!s} and {e}).
  • Merge for release v1.1.5 #175 — modifies pipeline preparation paths (_prepare_for_chat_completion/_prepare_for_completions) similar to multi-query/document assembly changes.
  • Feat/chunking #165 — touches document-retrieval and token-aware context utilities that the new batch retrieval and context assembly build upon.

Suggested reviewers

  • paultranvan
  • dodekapod

Poem

🐰 Hopping through code with a curious twitch,
Queries splintered, then gathered in a stitch.
RRF hums, lists fuse into one,
SearchQueries dance under the sun.
Hooray — more paths to answers run! ✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 20.83% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately reflects the main changes: query decomposition implementation and reranking enhancement with SearchQueries model and RRF fusion logic.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/query_decomposition_for_search
📝 Coding Plan for PR comments
  • Generate coding plan

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@Ahmath-Gadji
Ahmath-Gadji force-pushed the feat/query_decomposition_for_search branch 2 times, most recently from 6b6fc2e to 5de9a99 Compare March 5, 2026 08:35
@Ahmath-Gadji
Ahmath-Gadji marked this pull request as ready for review March 5, 2026 08:42
@coderabbitai coderabbitai Bot added the feat Add a new feature label Mar 5, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (1)
openrag/components/pipeline.py (1)

84-85: Align partition type annotation with actual usage.

get_relevant_docs declares partition: str, but it is used/called as list[str] in this pipeline path.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@openrag/components/pipeline.py` around lines 84 - 85, The partition parameter
on get_relevant_docs is annotated as str but callers pass list[str]; update the
function signature for get_relevant_docs to accept a sequence of strings (e.g.,
partition: list[str] or Sequence[str]) and adjust any internal handling to
iterate over partitions instead of treating it as a single string; ensure
related references to partition inside the function (and any callers expecting a
str) are updated to use the new type and import typing.Sequence if chosen.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@openrag/components/pipeline.py`:
- Around line 86-90: The RRF fusion currently returns a combined list that can
exceed the intended top_k budget; after calling
self.reranker.rrf_reranking(doc_lists=results) you must enforce the top_k cap by
deduplicating results (by document id or unique key) and trimming to the top_k
highest-scoring entries before returning. Update the code around retrieve_docs,
self.reranker.rrf_reranking, and the return path to collapse duplicates, sort by
the reranker score, and slice to top_k so the final returned list respects the
top_k parameter.
- Line 21: The import uses a relative import for utils (importing
detect_language and format_context); replace that relative import with an
absolute import from the package root (openrag.utils) so the module imports
detect_language and format_context via the absolute package path rather than
using a leading dot; update any similar relative imports in the same module if
present to follow the same absolute-import pattern.
- Around line 131-133: The code is incorrectly putting model-specific parameters
into a RunnableConfig-like dict named params (setting
params["max_completion_tokens"] and params["extra_body"]) which won't be
forwarded to the OpenAI model; update the call site that constructs params in
the Pipeline (and the similar block around the second occurrence) to pass model
kwargs via the model's .bind() or by setting them directly on the model instance
(use self.model.bind(max_completion_tokens=self.max_contextualized_query_len,
extra_body={"chat_template_kwargs": {"enable_thinking": False}}) or set
equivalent attributes on the model before execution) instead of placing them
into params/config so the OpenAI API receives them.

In `@prompts/example1/query_contextualizer_tmpl.txt`:
- Line 15: Fix the typo in the template string inside
query_contextualizer_tmpl.txt by replacing "carbone footprint" with "carbon
footprint" and update the phrase "in the last 2" to "in the last 2 years" so the
example query reads "Evolution of carbon footprint in the last 2 years" (this
will produce two sub-queries as intended); locate and edit the example line
containing the quoted phrase to apply this change.

---

Nitpick comments:
In `@openrag/components/pipeline.py`:
- Around line 84-85: The partition parameter on get_relevant_docs is annotated
as str but callers pass list[str]; update the function signature for
get_relevant_docs to accept a sequence of strings (e.g., partition: list[str] or
Sequence[str]) and adjust any internal handling to iterate over partitions
instead of treating it as a single string; ensure related references to
partition inside the function (and any callers expecting a str) are updated to
use the new type and import typing.Sequence if chosen.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 9e6b1640-0673-4c9c-aa1e-bf8cad421c65

📥 Commits

Reviewing files that changed from the base of the PR and between 26c5ab3 and 5de9a99.

⛔ Files ignored due to path filters (1)
  • uv.lock is excluded by !**/*.lock
📒 Files selected for processing (6)
  • openrag/app_front.py
  • openrag/components/pipeline.py
  • openrag/components/reranker.py
  • openrag/components/test_rrf_reranking.py
  • prompts/example1/query_contextualizer_tmpl.txt
  • tests/api_tests/api_run/mock_vllm.py

Comment thread openrag/components/pipeline.py Outdated
Comment thread openrag/components/pipeline.py Outdated
Comment thread openrag/components/pipeline.py Outdated
Comment thread prompts/example1/query_contextualizer_tmpl.txt Outdated
@EnjoyBacon7
EnjoyBacon7 force-pushed the feat/query_decomposition_for_search branch from 5de9a99 to 7e55e4f Compare March 12, 2026 13:41
@EnjoyBacon7
EnjoyBacon7 merged commit a662eed into dev Mar 12, 2026
3 of 4 checks passed
@EnjoyBacon7
EnjoyBacon7 deleted the feat/query_decomposition_for_search branch March 12, 2026 13:49
@EnjoyBacon7

Copy link
Copy Markdown
Collaborator

Bug: get_relevant_docs()partition parameter typed as str instead of list[str]

get_relevant_docs declared partition: str, but retrieve_docs() (and the underlying Milvus search) expects list[str]. Callers in the pipeline always pass a list.

Fixed in rebase commit 55af5af — signature updated to partition: list[str].

@EnjoyBacon7

Copy link
Copy Markdown
Collaborator

Bug: get_relevant_docs()filter parameter accepted but silently dropped

The filter: dict | None = None parameter was declared in the signature but never forwarded to retrieve_docs() inside the asyncio.gather call. Any caller passing a filter would have it silently ignored, causing incorrect retrieval results.

Fixed in rebase commit 55af5affilter=filter is now passed to every retrieve_docs() task.

@EnjoyBacon7

Copy link
Copy Markdown
Collaborator

Web search multi-query strategy: Option C (one search per sub-query, concurrent)

For requests combining RAG + web search, the implementation runs one web_search_service.search(q) call per sub-query concurrently via asyncio.gather, then deduplicates results by WebResult.url (first-seen order) before passing to format_web_context.

Alternatives considered:

  • Option A — concatenate sub-queries into a single search string: loses the semantic separation that decomposition provides.
  • Option B — search only with the first sub-query: wastes decomposition entirely for web results.
  • Option C ✅ — leverages decomposition for web search too, at the cost of slightly more API calls. Acceptable since web search is opt-in and sub-query count is bounded.

Implemented in rebase commit 55af5af.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
tests/api_tests/api_run/mock_vllm.py (1)

240-242: Emit more than one query for SearchQueries arrays.

Because every array field is hard-coded to [user_text], this path still drives API tests through a single retrieval request. For the new SearchQueries.queries schema, that means the API path never exercises the multi-query branch or any cross-query fusion behavior. A deterministic 2–3 item payload here would give the new flow real coverage.

Also applies to: 283-285

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/api_tests/api_run/mock_vllm.py` around lines 240 - 242, The current
mock generator sets every array property to a single-item list ([user_text]),
which prevents exercising multi-query behavior (e.g., SearchQueries.queries);
update the logic in the loop that builds mock_args (the for prop_name,
prop_schema in properties.items() block) to produce a deterministic multi-item
array (2–3 entries) for array-typed schemas—use repeated or slightly varied
values derived from user_text so tests deterministically hit the multi-query and
cross-query fusion branches; apply the same change to the similar block around
lines handling properties at the other occurrence (the block referenced as also
applies to: 283-285).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@openrag/components/pipeline.py`:
- Around line 183-184: The current debug log prints raw generated queries
(queries from generate_query) which may contain PII; change the logging in the
code that calls generate_query so it does NOT include the full queries
string—use a safe summary instead (e.g., log the count of queries and a
redacted/hashed summary or the length of each query). Specifically, update the
logger.debug call that references queries (the variable returned by
generate_query) to emit only non-sensitive metadata (counts, lengths, or a
redacted preview) or call a helper redact_summary(queries) before logging.
- Around line 152-176: The query generator's returned SearchQueries object must
have its query_list normalized before return: after calling
self.query_generator.bind(...).ainvoke(messages), take output.query_list, strip
whitespace from each entry, remove empty strings, deduplicate while preserving
order, and if the resulting list is empty, replace it with a single-item list
containing the original user query (capture the original user message content
before you overwrite messages—e.g., save the pre-format user query from
messages[-1]["content"] or chat_history). Update output.query_list with the
cleaned list and then return output.

---

Nitpick comments:
In `@tests/api_tests/api_run/mock_vllm.py`:
- Around line 240-242: The current mock generator sets every array property to a
single-item list ([user_text]), which prevents exercising multi-query behavior
(e.g., SearchQueries.queries); update the logic in the loop that builds
mock_args (the for prop_name, prop_schema in properties.items() block) to
produce a deterministic multi-item array (2–3 entries) for array-typed
schemas—use repeated or slightly varied values derived from user_text so tests
deterministically hit the multi-query and cross-query fusion branches; apply the
same change to the similar block around lines handling properties at the other
occurrence (the block referenced as also applies to: 283-285).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: f9eddf5f-1ce2-434e-b3dc-887efdede102

📥 Commits

Reviewing files that changed from the base of the PR and between 5de9a99 and 7e55e4f.

📒 Files selected for processing (6)
  • openrag/app_front.py
  • openrag/components/pipeline.py
  • openrag/components/reranker.py
  • openrag/components/test_rrf_reranking.py
  • prompts/example1/query_contextualizer_tmpl.txt
  • tests/api_tests/api_run/mock_vllm.py
🚧 Files skipped from review as they are similar to previous changes (2)
  • prompts/example1/query_contextualizer_tmpl.txt
  • openrag/components/test_rrf_reranking.py

Comment on lines +152 to +176
query_language = detect_language(messages[-1]["content"])

model_kwargs = {
"max_completion_tokens": self.max_contextualized_query_len,
"extra_body": {"chat_template_kwargs": {"enable_thinking": False}},
}
prompt = QUERY_CONTEXTUALIZER_PROMPT.format(
query_language=query_language,
current_date=datetime.now().strftime("%Y-%m-%d"),
)
contextualized_query = response.choices[0].message.content
return contextualized_query

messages = [
{
"role": "system",
"content": prompt,
},
{
"role": "user",
"content": f"Here is the chat history: \n{chat_history}\n",
},
]

# generate queries based on the chat history
output: SearchQueries = await self.query_generator.bind(**model_kwargs).ainvoke(messages)
return output

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Normalize the generated query_list before returning it.

The structured output is used verbatim downstream. Blank or duplicate items trigger redundant retrieval/web-search calls and repeated queries get extra weight in RRF; an empty list skips retrieval entirely. Strip, dedupe, and fall back to the original user query here.

Suggested fix
-                query_language = detect_language(messages[-1]["content"])
+                original_query = messages[-1]["content"]
+                query_language = detect_language(original_query)
@@
-                messages = [
+                query_messages = [
                     {
                         "role": "system",
                         "content": prompt,
                     },
                     {
                         "role": "user",
                         "content": f"Here is the chat history: \n{chat_history}\n",
                     },
                 ]
@@
-                output: SearchQueries = await self.query_generator.bind(**model_kwargs).ainvoke(messages)
-                return output
+                output: SearchQueries = await self.query_generator.bind(**model_kwargs).ainvoke(query_messages)
+
+                normalized_queries = []
+                seen = set()
+                for query in output.query_list:
+                    normalized_query = query.strip()
+                    if normalized_query and normalized_query not in seen:
+                        seen.add(normalized_query)
+                        normalized_queries.append(normalized_query)
+
+                return SearchQueries(query_list=normalized_queries or [original_query])
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@openrag/components/pipeline.py` around lines 152 - 176, The query generator's
returned SearchQueries object must have its query_list normalized before return:
after calling self.query_generator.bind(...).ainvoke(messages), take
output.query_list, strip whitespace from each entry, remove empty strings,
deduplicate while preserving order, and if the resulting list is empty, replace
it with a single-item list containing the original user query (capture the
original user message content before you overwrite messages—e.g., save the
pre-format user query from messages[-1]["content"] or chat_history). Update
output.query_list with the cleaned list and then return output.

Comment on lines +183 to +184
queries: SearchQueries = await self.generate_query(messages)
logger.debug("Prepared query for chat completion", queries=str(queries))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don’t log raw generated queries.

These strings are derived from user chat history and can contain PII or secrets. Log counts or a redacted summary instead of the full query text.

Suggested fix
-        logger.debug("Prepared query for chat completion", queries=str(queries))
+        logger.debug(
+            "Prepared query for chat completion",
+            query_count=len(queries.query_list),
+            decomposed=len(queries.query_list) > 1,
+        )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@openrag/components/pipeline.py` around lines 183 - 184, The current debug log
prints raw generated queries (queries from generate_query) which may contain
PII; change the logging in the code that calls generate_query so it does NOT
include the full queries string—use a safe summary instead (e.g., log the count
of queries and a redacted/hashed summary or the length of each query).
Specifically, update the logger.debug call that references queries (the variable
returned by generate_query) to emit only non-sensitive metadata (counts,
lengths, or a redacted preview) or call a helper redact_summary(queries) before
logging.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feat Add a new feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants