[NPU] Add Search-R1 example#311
Conversation
Documentation build overview
43 files changed ·
|
There was a problem hiding this comment.
Code Review
This pull request introduces 'Search-R1 lite', a minimal reproduction of Search-R1 supporting both local dense retrieval (FAISS + E5) and Google Search backends, complete with GRPO-based RL training scripts, evaluation metrics, and documentation. The code review identified several critical issues, including a logic error in extract_solution that breaks zero-shot RL training, an indexing/alignment bug in the Google search server when items lack links, and an unpacking bug in the retrieval server when batch size is exactly two. Additionally, the reviewer pointed out a list length mismatch in the BM25 retriever, an ignored --repo_id argument in the download script, and type annotation mismatches in execute_predictions and is_retrieval_correct.
| # If there are 0 or exactly 1 matches, return None | ||
| if len(matches) <= 1: | ||
| return None |
There was a problem hiding this comment.
In zero-shot RL training, there is exactly 1 <answer> block in the generated response and 0 in the prompt. If len(matches) <= 1 returns None, then extract_solution will always return None for correct zero-shot responses, completely breaking the reward signal and preventing the model from receiving a positive reward. We should check if len(matches) == 0 instead.
| # If there are 0 or exactly 1 matches, return None | |
| if len(matches) <= 1: | |
| return None | |
| # If there are no matches, return None | |
| if len(matches) == 0: | |
| return None |
| links = [item.get("link", "") for item in items if "link" in item] | ||
| web_contents = await fetch_all(links) |
There was a problem hiding this comment.
Filtering links using if "link" in item can result in len(links) being smaller than len(items). When iterating over items and accessing web_contents[i], this will raise an IndexError or cause incorrect alignment between the items and their fetched web contents. Since fetch already handles empty URLs gracefully, we should keep the list aligned by including empty strings for items without links.
| links = [item.get("link", "") for item in items if "link" in item] | |
| web_contents = await fetch_all(links) | |
| links = [item.get("link", "") for item in items] | |
| web_contents = await fetch_all(links) |
| scores = [] | ||
| try: | ||
| results, scores = tmp | ||
| except ValueError: | ||
| results = tmp |
There was a problem hiding this comment.
There is an unpacking bug when return_scores is False and the batch size is exactly 2. In this case, tmp is a list of length 2 (containing results for both queries). The try-except block will successfully unpack tmp into results and scores, which is incorrect because scores should be empty and results should contain the full list of results. We should explicitly check request.return_scores instead of relying on a try-except block.
| scores = [] | |
| try: | |
| results, scores = tmp | |
| except ValueError: | |
| results = tmp | |
| if request.return_scores: | |
| results, scores = tmp | |
| else: | |
| results = tmp | |
| scores = [] |
| return action, content | ||
|
|
||
|
|
||
| async def execute_predictions(prediction: str) -> str: |
There was a problem hiding this comment.
The type annotation for execute_predictions indicates that it returns a str, but the function actually returns a tuple of (str, bool) (i.e., next_obs, done). This type mismatch can cause static analysis or type-checking tools to report errors.
| async def execute_predictions(prediction: str) -> str: | |
| async def execute_predictions(prediction: str) -> tuple[str, bool]: |
| scores = [hit.score for hit in hits] | ||
| if len(hits) < num: | ||
| warnings.warn("Not enough documents retrieved!", stacklevel=2) | ||
| else: | ||
| hits = hits[:num] |
There was a problem hiding this comment.
The scores list is populated from hits before hits is sliced to num. This results in a length mismatch where scores has more elements than results. We should compute scores after slicing hits to ensure their lengths are aligned.
| scores = [hit.score for hit in hits] | |
| if len(hits) < num: | |
| warnings.warn("Not enough documents retrieved!", stacklevel=2) | |
| else: | |
| hits = hits[:num] | |
| if len(hits) < num: | |
| warnings.warn("Not enough documents retrieved!", stacklevel=2) | |
| else: | |
| hits = hits[:num] | |
| scores = [hit.score for hit in hits] |
| return [match.strip() for match in matches] | ||
|
|
||
|
|
||
| def is_retrieval_correct(text: str, golden_answers: list[str]) -> list[str]: |
There was a problem hiding this comment.
The type annotation for is_retrieval_correct indicates that it returns list[str], but the function actually returns a boolean (True or False). This type mismatch should be corrected.
| def is_retrieval_correct(text: str, golden_answers: list[str]) -> list[str]: | |
| def is_retrieval_correct(text: str, golden_answers: list[str]) -> bool: |
|
|
||
| args = parser.parse_args() | ||
|
|
||
| repo_id = "PeterJinGo/wiki-18-e5-index" |
There was a problem hiding this comment.
| SEMAPHORE = asyncio.Semaphore(SEARCH_R1_CONFIGS["search_concurrency"]) | ||
|
|
||
|
|
||
| def _build_inference_sampling_params(sampling_params: dict) -> dict: |
There was a problem hiding this comment.
Could we import the framework helper? to avoid drift when rollout sampling mapping changes. vime.rollout.vllm_rollout._build_inference_sampling_params
|
Why do we see sharp peaks on the diff curve? |
|
Results show training reward and logprob diff only. To claim Search-R1 effectiveness (not just pipeline health), can you add periodic eval EM on NQ/HotpotQA test (uncomment --eval-prompt-data) and a base vs trained EM table? Slime example has the hooks but no numbers either — we'd like vime ascend to be the first with a minimal reproducible EM curve. |
We suspect the issue arises from partial timeouts in the retrieval service rather than excessive overall load. We have reduced the concurrency, rerun the experiment, and updated the reward and diff curves. |
We provide evaluation results sampled at 50-step intervals within the first 200 training steps, which demonstrate a steady improvement in evaluation scores. |
Signed-off-by: gaofulin <gaofulin1@huawei.com>
Signed-off-by: gaofulin <gaofulin1@huawei.com>
Signed-off-by: gaofulin <gaofulin1@huawei.com>
Signed-off-by: gaofulin <gaofulin1@huawei.com>
Search-R1 lite
This example is ported from slime (Megatron + SGLang) and adapted for vime (Megatron + vLLM). It provides minimal reproduction of Search-R1, demonstrating multi-turn conversation and tool-calling workflows in vime.
Overview
The Search-R1 example provides:
Files
generate_with_search.pygoogle_search_server.pylocal_search_server.pyqa_em_format.pyrun_qwen3_4b_npu.shlocal_dense_retriever/retrieval_server.pylocal_dense_retriever/download.pyResults
Closes #302