Files
wehub-resource-sync 542cfa195c
CI / Frontend build (push) Failing after 9m6s
CI / Plugin validate (push) Failing after 9m27s
CI / Python lint (push) Failing after 16m1s
CI / Tests (push) Successful in 18m0s
Deploy / deploy (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:27 +08:00

1998 lines
74 KiB
Python

"""Run QA benchmark evaluation with various retrieval strategies.
Supports: SimpleQA, NQ, NQ-Tables, EVQA, MMSearch, WorldVQA, SimpleVQA, etc.
Usage:
# Naive (no retrieval)
python run_bench.py --task simpleqa --model Qwen/Qwen3.5-4B-Instruct --no-think
# Pixel retrieval via search API
python run_bench.py --task simpleqa --model Qwen/Qwen3.5-4B-Instruct --local-api --no-think
# Text retrieval via search API
python run_bench.py --task simpleqa --model Qwen/Qwen3.5-4B-Instruct --text-api --no-think
# OpenRouter API (no local vLLM)
python run_bench.py --task simpleqa --model openai/gpt-4 --open-router --api-key sk-or-v1-xxx
"""
import argparse
import asyncio
import json
import logging
import os
import sys
import time
from tqdm.asyncio import tqdm_asyncio
# Logging setup
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(levelname)s - %(message)s",
handlers=[logging.FileHandler("run_naive.log"), logging.StreamHandler()],
)
logger = logging.getLogger(__name__)
# Add agent root to python path
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
from lib import (
# Data
load_simpleqa_wikipedia,
extract_url_from_metadata,
encode_screenshot,
make_compressed_encoder,
load_nq_data,
load_triviaqa_data,
load_nq_tables_data,
load_piqa_data,
load_hellaswag_data,
load_commonsenseqa_data,
load_openbookqa_data,
load_arc_data,
)
from lib import LLMClient, build_messages, build_react_messages
from lib.model_config import get_model_config, get_output_filename
from lib.retrieval import (
_get_query_image_path_for_example,
_save_worldvqa_query_image,
_save_task_query_image,
)
from lib.benchmarks import (
load_encyclopedic_vqa_data,
load_shortformqa_data,
load_worldvqa_data,
load_simplevqa_data,
load_factualvqa_data,
load_mmsearch_data,
load_webqa_data,
load_multimodalqa_data,
SUPPORTED_TASKS as DATASET_REPOS,
)
_DEFAULT_SPLIT_FOR_TASK = {
"simpleqa": "test",
"simpleqa_verified": "verified",
"encyclopedic_vqa": "test",
"worldvqa": "test",
"2wiki": "validation",
"simplevqa": "test",
"factualvqa": "test",
"mmsearch": "end2end",
"webqa": "test",
"multimodalqa": "validation",
"nq": "validation",
"triviaqa": "validation",
"nq_tables": "dev",
"piqa": "validation",
"hellaswag": "validation",
"commonsense_qa": "validation",
"openbookqa": "validation",
"arc_easy": "validation",
"arc_challenge": "validation",
}
def _fetch_status(api_url: str | None, timeout: float = 5.0) -> dict | None:
"""Fetch /status from a search-API URL for reproducibility tagging.
Returns the JSON dict on success, or {"_error": str, "url": str} on failure
(failure is recorded rather than raised so a missing service does not block the run).
"""
if not api_url:
return None
import urllib.request
base = api_url.rstrip("/")
if base.endswith("/search"):
base = base[: -len("/search")]
status_url = base + "/status"
try:
with urllib.request.urlopen(status_url, timeout=timeout) as r:
return json.loads(r.read().decode())
except Exception as e: # noqa: BLE001 — best-effort capture
return {"_error": f"{type(e).__name__}: {e}", "url": status_url}
def _build_run_metadata(args, n_loaded: int) -> dict:
"""Build the per-run reproducibility tuple stamped into every JSONL record.
See root CLAUDE.md "Reproducibility tagging" — every benchmark number must
carry: dataset+split+n, reader, retriever+checkpoint, index path+vec+built_at,
top-k, query instruction, grader.
"""
import datetime
import subprocess
reader_top_k = (
args.reader_top_k if args.reader_top_k is not None else args.retrieval_top_k
)
meta = {
"schema_version": 1,
"run_started_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
# Dataset + split + n
"task": args.task,
"split": getattr(args, "nq_split", None)
if args.task == "nq"
else _DEFAULT_SPLIT_FOR_TASK.get(args.task, "unknown"),
"num_examples_requested": args.num_examples,
"num_examples_loaded": n_loaded,
# Reader
"reader_model": args.model,
"reader_max_tokens": getattr(args, "max_tokens", None),
"reader_no_think": getattr(args, "no_think", False),
"reader_extra_instructions": getattr(args, "reader_extra_instructions", None),
# Retrieval k vs reader k (decoupled)
"retrieval_top_k": args.retrieval_top_k,
"reader_top_k": reader_top_k,
# Query instruction (verbatim)
"query_instruction": getattr(args, "query_instruction", None),
# Retrieval API URLs + their /status (captures index path, vec count, built_at, model)
"local_api_url": getattr(args, "local_api_url", None),
"text_api_url": getattr(args, "text_api_url", None),
"local_api_status": _fetch_status(getattr(args, "local_api_url", None)),
"text_api_status": _fetch_status(getattr(args, "text_api_url", None)),
# Misc dataset flags that change semantics
"verified": getattr(args, "verified", False),
"no_wiki_filter": getattr(args, "no_wiki_filter", False),
}
try:
meta["git_commit"] = (
subprocess.check_output(
["git", "rev-parse", "HEAD"],
cwd=os.path.dirname(os.path.abspath(__file__)),
stderr=subprocess.DEVNULL,
)
.decode()
.strip()
)
except Exception: # noqa: BLE001
meta["git_commit"] = None
return meta
async def process_example(
llm_client: LLMClient,
retriever,
example: dict,
semaphore: asyncio.Semaphore,
output_file: str | None = None,
progress_counter: dict | None = None,
total_examples: int = 0,
encode_image_fn=None,
task_name: str = "simpleqa",
tiles_dir: str | None = None,
run_metadata: dict | None = None,
) -> dict | None:
"""Process a single example: retrieve -> build messages -> call LLM."""
async with semaphore:
try:
example_id = example.get("id", "unknown")
# logger.info(f"Starting processing example {example_id}")
# 1. Retrieve (data preparation happens inside retriever if needed)
logger.debug(f"Retrieving for example {example_id}")
retrieval_start_time = time.time()
retrieval_result = await retriever.retrieve(example["problem"], example)
retrieval_time = time.time() - retrieval_start_time
logger.debug(
f"Retrieval complete for example {example_id} (took {retrieval_time:.2f}s)"
)
# 1a. Snapshot the full retrieved set BEFORE reader-side slicing.
# The JSONL records the full set so downstream grading can re-derive k=1/2/3
# from a single retrieval-top-k=K_max run without re-querying the index.
retrieved_full_images = (
list(retrieval_result.images) if retrieval_result.images else []
)
retrieved_full_image_urls = list(
getattr(retrieval_result, "image_urls", []) or []
)
# 1b. Reader-side top-k (decoupled from retrieval-k). Slice in place so build_messages
# and the LLM see only the first reader_top_k items.
reader_top_k = (run_metadata or {}).get("reader_top_k")
if (
reader_top_k is not None
and retrieval_result.images
and reader_top_k < len(retrieval_result.images)
):
retrieval_result.images = retrieval_result.images[:reader_top_k]
if getattr(retrieval_result, "image_urls", None):
retrieval_result.image_urls = retrieval_result.image_urls[
:reader_top_k
]
urls = []
seen_urls = set()
for url in getattr(retrieval_result, "image_urls", []) or []:
if url and url not in seen_urls:
seen_urls.add(url)
urls.append(url)
if not urls and retrieval_result.source_url:
for url in retrieval_result.source_url.split(", "):
if url and url not in seen_urls:
seen_urls.add(url)
urls.append(url)
if len(urls) >= reader_top_k:
break
if urls:
retrieval_result.source_url = ", ".join(urls)
# 1b. Attach query image so VLM sees it alongside retrieved tiles
if not retrieval_result.query_image_path and retrieval_result.has_content:
if task_name == "encyclopedic_vqa":
tiles_dir = getattr(retriever, "tiles_dir", None) or "tiles/evqa"
img_path = _get_query_image_path_for_example(
example, tiles_dir, quiet=True
)
if img_path:
retrieval_result.query_image_path = img_path
elif task_name in (
"worldvqa",
"simplevqa",
"factualvqa",
"mmsearch",
"webqa",
"multimodalqa",
):
img_path = _save_task_query_image(
example, task_name, base_dir="tiles"
)
if img_path:
retrieval_result.query_image_path = img_path
# 2. Build messages
logger.debug(f"Building messages for example {example_id}")
_encode_fn = (
encode_image_fn if encode_image_fn is not None else encode_screenshot
)
messages = build_messages(
query=example["problem"],
retrieval_result=retrieval_result,
encode_image_fn=_encode_fn,
additional_instructions=example.get("additional_instructions"),
few_shot_demos=example.get("_reader_few_shot"),
)
logger.debug(f"Messages built for example {example_id}")
# 3. Call LLM
# logger.info(f"Calling LLM for example {example_id}")
llm_start_time = time.time()
generated_text, usage = await llm_client.generate(messages)
llm_time = time.time() - llm_start_time
# Update progress counter
if progress_counter is not None:
progress_counter["completed"] += 1
completed = progress_counter["completed"]
((completed / total_examples * 100) if total_examples > 0 else 0)
# Accumulate timing stats
if "retrieval_times" not in progress_counter:
progress_counter["retrieval_times"] = []
progress_counter["llm_times"] = []
progress_counter["retrieval_times"].append(retrieval_time)
progress_counter["llm_times"].append(llm_time)
# 4. Build result
result = {
"example_id": example["id"],
# 0-indexed position in the loaded examples list — see run_async() stamping.
# Async writes append in completion order; sort downstream by load_index
# to recover canonical load order and the strict line-level prefix property
# (records with load_index < N are exactly the first N loaded examples).
"load_index": example.get("_load_index"),
"problem": example["problem"],
"model": llm_client.model,
"final_response": generated_text,
"original_data": {
k: v
for k, v in example.items()
if not hasattr(v, "save") and not k.startswith("_")
},
"full_traces": {},
"dataset_name": task_name,
"retrieval_type": retrieval_result.retrieval_type,
"has_retrieval_content": retrieval_result.has_content,
"usage": usage,
"success": True,
"timing": {
"retrieval_time": retrieval_time,
"llm_time": llm_time,
"total_time": retrieval_time + llm_time,
},
# Per-record reproducibility tag — see root CLAUDE.md "Reproducibility tagging".
# Stamped on every record so any single line is self-describing.
"run_metadata": run_metadata,
}
# Add retrieval-specific info
if retrieval_result.source_url:
result["used_url"] = retrieval_result.source_url
if retrieval_result.text:
result["context_length"] = len(retrieval_result.text)
# `retrieved_images` records the FULL retrieved set (pre reader-side slicing)
# so downstream grading at k=1/2/3 can be derived from one retrieval_top_k=K_max run.
if retrieved_full_images:
result["retrieved_images"] = []
for idx, (path, score) in enumerate(retrieved_full_images):
item = {"path": path, "score": score}
if (
idx < len(retrieved_full_image_urls)
and retrieved_full_image_urls[idx]
):
item["url"] = retrieved_full_image_urls[idx]
result["retrieved_images"].append(item)
if retrieval_result.pixel_query_path:
result["pixel_query_path"] = retrieval_result.pixel_query_path
# Always include query image path in result for eval analysis
query_img_path = (
retrieval_result.query_image_path or retrieval_result.pixel_query_path
)
if not query_img_path:
if task_name == "encyclopedic_vqa" and tiles_dir:
query_img_path = _get_query_image_path_for_example(
example, tiles_dir
)
elif task_name == "worldvqa":
query_img_path = _save_worldvqa_query_image(
example, base_dir="tiles"
)
elif task_name in (
"simplevqa",
"factualvqa",
"mmsearch",
"webqa",
"multimodalqa",
):
query_img_path = _save_task_query_image(
example, task_name, base_dir="tiles"
)
if query_img_path:
result["query_image_path"] = query_img_path
# Record compressed image paths if pixel compression was used
if (
_encode_fn is not None
and hasattr(_encode_fn, "compressed_paths")
and retrieval_result.images
):
compressed_images = []
for orig_path, score in retrieval_result.images:
comp_path = _encode_fn.compressed_paths.get(orig_path)
if comp_path:
compressed_images.append(
{
"original_path": orig_path,
"compressed_path": comp_path,
"score": score,
}
)
if compressed_images:
result["compressed_images"] = compressed_images
result["pixel_compress_ratio"] = _encode_fn.compress_ratio
result["compressed_images_dir"] = _encode_fn.save_dir
# Incremental save
if output_file:
with open(output_file, "a") as f:
f.write(json.dumps(result) + "\n")
return result
except Exception as e:
import traceback
error_trace = traceback.format_exc()
example_id = example.get("id", "unknown")
# Update progress counter even on error
if progress_counter is not None:
progress_counter["completed"] += 1
logger.warning(f"Example {example_id} failed: {e}")
logger.error(f"Error processing {example_id}: {e}")
logger.error(f"Traceback: {error_trace}")
result = {
"example_id": example.get("id"),
"problem": example.get("problem"),
"model": llm_client.model,
"final_response": None,
"original_data": {
k: v
for k, v in example.items()
if not hasattr(v, "save") and not k.startswith("_")
},
"dataset_name": task_name,
"success": False,
"error": str(e),
"error_type": type(e).__name__,
"timing": {
"retrieval_time": None,
"llm_time": None,
"total_time": None,
},
}
if output_file:
with open(output_file, "a") as f:
f.write(json.dumps(result) + "\n")
return result
import re
_SEARCH_TAG_RE = re.compile(r"<search>(.*?)</search>", re.DOTALL)
async def _local_api_search(
api_url: str, query_text: str, top_k: int, nprobe: int | None = None
) -> list[dict]:
"""Single-query search against local API, returns hits."""
import aiohttp
payload = {"queries": [{"text": query_text}], "n_docs": top_k}
if nprobe is not None:
payload["nprobe"] = nprobe
try:
async with aiohttp.ClientSession() as session:
async with session.post(
api_url,
json=payload,
timeout=aiohttp.ClientTimeout(total=300),
) as response:
if response.status != 200:
return []
result = await response.json()
results_list = result.get("results", [])
return results_list[0].get("hits", []) if results_list else []
except Exception as e:
logger.error(f"ReAct search failed: {e}")
return []
def _hits_to_retrieval_result(hits: list[dict]) -> "RetrievalResult": # noqa: F821
"""Convert API hits to RetrievalResult (same logic as LocalAPIRetriever)."""
from lib.retrieval import RetrievalResult
if not hits:
return RetrievalResult(retrieval_type="local_api_react")
images = []
image_urls = []
urls = []
seen_urls = set()
for hit in hits:
path = hit.get("path", "")
score = hit.get("score", 0.0)
url = hit.get("url", "")
if path and os.path.exists(path):
images.append((path, score))
image_urls.append(url or None)
if url and url not in seen_urls:
seen_urls.add(url)
urls.append(url)
return RetrievalResult(
images=images,
image_urls=image_urls,
source_url=", ".join(urls) if urls else None,
retrieval_type="local_api_react",
)
async def process_example_react(
llm_client: LLMClient,
retriever,
example: dict,
semaphore: asyncio.Semaphore,
output_file: str | None = None,
progress_counter: dict | None = None,
total_examples: int = 0,
encode_image_fn=None,
task_name: str = "simpleqa",
tiles_dir: str | None = None,
max_turns: int = 3,
api_url: str = "http://localhost:30888/search",
react_top_k: int = 5,
nprobe: int | None = None,
prompt_version: str = "v1",
) -> dict | None:
"""Process a single example with ReAct multi-turn retrieval.
Flow: retrieve → LLM → if <search>query</search> in response → retrieve again → LLM → ...
Stops when: (1) no <search> tag in response, (2) max_turns reached, or (3) error.
"""
async with semaphore:
try:
example_id = example.get("id", "unknown")
total_start = time.time()
# Round 1: use the normal retriever (which may have prefetched results)
retrieval_start = time.time()
retrieval_result = await retriever.retrieve(example["problem"], example)
retrieval_time = time.time() - retrieval_start
retrieval_results = [retrieval_result]
assistant_responses = []
all_search_queries = []
total_retrieval_time = retrieval_time
total_llm_time = 0.0
turns_used = 1
_encode_fn = (
encode_image_fn if encode_image_fn is not None else encode_screenshot
)
for turn in range(max_turns):
is_last = turn == max_turns - 1
# Build messages (multi-turn)
messages = build_react_messages(
query=example["problem"],
retrieval_results=retrieval_results,
assistant_responses=assistant_responses,
encode_image_fn=_encode_fn,
prompt_version=prompt_version,
is_last_turn=is_last,
previous_queries=all_search_queries,
)
# Call LLM
llm_start = time.time()
generated_text, usage = await llm_client.generate(messages)
total_llm_time += time.time() - llm_start
# Check for <search> tag
match = _SEARCH_TAG_RE.search(generated_text)
if not match or is_last:
# Final answer (or last turn forced)
# Strip any remaining <search> tags from forced-stop responses
final_response = _SEARCH_TAG_RE.sub("", generated_text).strip()
turns_used = turn + 1
break
# Extract search query and do another round
search_query = match.group(1).strip()
all_search_queries.append(search_query)
assistant_responses.append(generated_text)
logger.info(
f"ReAct [{example_id}] turn {turn + 1}: searching '{search_query[:80]}'"
)
# New retrieval
ret_start = time.time()
new_hits = await _local_api_search(
api_url, search_query, react_top_k, nprobe
)
total_retrieval_time += time.time() - ret_start
retrieval_results.append(_hits_to_retrieval_result(new_hits))
else:
final_response = generated_text
turns_used = max_turns
# Update progress counter
if progress_counter is not None:
progress_counter["completed"] += 1
if "retrieval_times" not in progress_counter:
progress_counter["retrieval_times"] = []
progress_counter["llm_times"] = []
progress_counter["retrieval_times"].append(total_retrieval_time)
progress_counter["llm_times"].append(total_llm_time)
total_time = time.time() - total_start
# Build per-turn traces (images + assistant response for each round)
react_trace = []
for turn_idx, rr in enumerate(retrieval_results):
turn_info = {
"turn": turn_idx + 1,
"images": [
{"path": path, "score": score, "url": rr.source_url}
for path, score in rr.images
],
}
if turn_idx < len(assistant_responses):
turn_info["assistant_response"] = assistant_responses[turn_idx]
elif turn_idx == len(retrieval_results) - 1:
# Last turn: the final_response is the answer
turn_info["assistant_response"] = final_response
react_trace.append(turn_info)
# Build result
result = {
"example_id": example["id"],
"problem": example["problem"],
"model": llm_client.model,
"final_response": final_response,
"original_data": {
k: v
for k, v in example.items()
if not hasattr(v, "save") and not k.startswith("_")
},
"full_traces": {},
"dataset_name": task_name,
"retrieval_type": "local_api_react",
"has_retrieval_content": any(r.has_content for r in retrieval_results),
"usage": usage,
"success": True,
"react_turns": turns_used,
"react_search_queries": all_search_queries,
"react_trace": react_trace,
"timing": {
"retrieval_time": total_retrieval_time,
"llm_time": total_llm_time,
"total_time": total_time,
},
}
# Add retrieval info from first round
if retrieval_results[0].source_url:
result["used_url"] = retrieval_results[0].source_url
if retrieval_results[0].images:
result["retrieved_images"] = [
{"path": path, "score": score}
for path, score in retrieval_results[0].images
]
# All retrieved images across all rounds
all_images = []
for rr in retrieval_results:
for path, score in rr.images:
all_images.append({"path": path, "score": score})
if len(retrieval_results) > 1:
result["all_retrieved_images"] = all_images
# Incremental save
if output_file:
with open(output_file, "a") as f:
f.write(json.dumps(result) + "\n")
return result
except Exception as e:
import traceback
error_trace = traceback.format_exc()
example_id = example.get("id", "unknown")
if progress_counter is not None:
progress_counter["completed"] += 1
logger.warning(f"ReAct example {example_id} failed: {e}")
logger.error(f"Error processing (react) {example_id}: {e}")
logger.error(f"Traceback: {error_trace}")
result = {
"example_id": example.get("id"),
"problem": example.get("problem"),
"model": llm_client.model,
"final_response": None,
"original_data": {
k: v
for k, v in example.items()
if not hasattr(v, "save") and not k.startswith("_")
},
"dataset_name": task_name,
"retrieval_type": "local_api_react",
"success": False,
"error": str(e),
"error_type": type(e).__name__,
"timing": {
"retrieval_time": None,
"llm_time": None,
"total_time": None,
},
}
if output_file:
with open(output_file, "a") as f:
f.write(json.dumps(result) + "\n")
return result
def print_statistics(results: list[dict], args) -> None:
"""Print evaluation statistics."""
total = len(results)
if total == 0:
print("No results to report.")
return
# Count success/failure
success_count = sum(1 for r in results if r.get("success", False))
failure_count = total - success_count
print("-" * 40)
print(f"Total: {total} examples")
print(f" Success: {success_count} ({success_count / total * 100:.1f}%)")
print(f" Failed: {failure_count} ({failure_count / total * 100:.1f}%)")
# Timing statistics
successful_results = [
r for r in results if r.get("success", False) and r.get("timing")
]
if successful_results:
retrieval_times = [
r["timing"]["retrieval_time"]
for r in successful_results
if r["timing"].get("retrieval_time") is not None
]
llm_times = [
r["timing"]["llm_time"]
for r in successful_results
if r["timing"].get("llm_time") is not None
]
total_times = [
r["timing"]["total_time"]
for r in successful_results
if r["timing"].get("total_time") is not None
]
if retrieval_times:
print(
f"\nTiming Statistics (for {len(successful_results)} successful requests):"
)
print(" Jina read time:")
print(f" Mean: {sum(retrieval_times) / len(retrieval_times):.2f}s")
print(f" Min: {min(retrieval_times):.2f}s")
print(f" Max: {max(retrieval_times):.2f}s")
print(
f" Median: {sorted(retrieval_times)[len(retrieval_times) // 2]:.2f}s"
)
if llm_times:
print(" LLM call time:")
print(f" Mean: {sum(llm_times) / len(llm_times):.2f}s")
print(f" Min: {min(llm_times):.2f}s")
print(f" Max: {max(llm_times):.2f}s")
print(f" Median: {sorted(llm_times)[len(llm_times) // 2]:.2f}s")
if total_times:
print(" Total time:")
print(f" Mean: {sum(total_times) / len(total_times):.2f}s")
print(f" Min: {min(total_times):.2f}s")
print(f" Max: {max(total_times):.2f}s")
print(f" Median: {sorted(total_times)[len(total_times) // 2]:.2f}s")
# Count by retrieval type (only for successful)
successful = [r for r in results if r.get("success", False)]
if successful:
type_counts = {}
for r in successful:
rt = r.get("retrieval_type", "unknown")
type_counts[rt] = type_counts.get(rt, 0) + 1
print("\nRetrieval types (successful only):")
for rt, count in type_counts.items():
print(f" {rt}: {count} ({count / len(successful) * 100:.1f}%)")
# Retrieval accuracy (for vector mode)
# Checks if any of the top-k retrieved tiles come from the correct Wikipedia page
if args.retrieval_augment or args.use_tiled_retrieval or args.local_api:
retrieval_results = [r for r in results if r.get("retrieved_images")]
if retrieval_results:
correct = 0
for r in retrieval_results:
# Try to get ground truth URL from metadata
gt_url = extract_url_from_metadata(r.get("original_data", {}))
if not gt_url:
# Fallback: match by example_id in tile filename
example_id = r.get("example_id", "")
for img_info in r.get("retrieved_images", []):
img_path = img_info.get("original_path") or img_info.get(
"path", ""
)
img_basename = os.path.basename(img_path)
if example_id in img_basename:
correct += 1
break
else:
# Check if any retrieved tile's URL matches the ground truth URL
# retrieved_url is a string of comma-separated URLs from tiles
retrieved_url = r.get("used_url", "")
# Check if the ground truth URL is contained in the retrieved URLs
if gt_url in retrieved_url:
correct += 1
print("\nRetrieval Accuracy:")
print(
f" Correct (top-{args.retrieval_top_k}): {correct}/{len(retrieval_results)} ({correct / len(retrieval_results) * 100:.1f}%)"
)
# ReAct turn statistics
react_results = [r for r in results if r.get("react_turns") is not None]
if react_results:
turns = [r["react_turns"] for r in react_results]
from collections import Counter
turn_counts = Counter(turns)
print("\nReAct Turn Distribution:")
for t in sorted(turn_counts):
print(
f" {t} turn(s): {turn_counts[t]} ({turn_counts[t] / len(react_results) * 100:.1f}%)"
)
print(f" Average turns: {sum(turns) / len(turns):.2f}")
multi_turn = sum(1 for t in turns if t > 1)
print(
f" Examples needing re-search: {multi_turn}/{len(react_results)} ({multi_turn / len(react_results) * 100:.1f}%)"
)
print("-" * 40)
print(f"Results saved to {args.output}")
async def run_async(args):
"""Main async entry point."""
# 1. Load data
if args.task == "simpleqa":
examples = load_simpleqa_wikipedia(
args.num_examples,
verified=args.verified,
no_wiki_filter=getattr(args, "no_wiki_filter", False),
)
elif args.task == "encyclopedic_vqa":
split = args.subset or "val"
examples = load_encyclopedic_vqa_data(
split,
args.num_examples,
dataset_filter=args.evqa_dataset_filter,
question_type_filter=args.evqa_question_type_filter,
local_path=args.evqa_data_path,
)
if args.evqa_instruction_override is not None:
for ex in examples:
ex["additional_instructions"] = args.evqa_instruction_override
elif args.task == "worldvqa":
examples = load_worldvqa_data(
args.num_examples, language_filter=getattr(args, "worldvqa_language", None)
)
elif args.task == "2wiki":
dataset_repo = DATASET_REPOS["2wiki"]
examples = load_shortformqa_data(dataset_repo, args.num_examples)
elif args.task == "simplevqa":
examples = load_simplevqa_data(args.num_examples)
elif args.task == "factualvqa":
examples = load_factualvqa_data(args.num_examples)
elif args.task == "mmsearch":
examples = load_mmsearch_data(args.num_examples)
elif args.task == "webqa":
examples = load_webqa_data(args.num_examples)
elif args.task == "multimodalqa":
examples = load_multimodalqa_data(args.num_examples)
elif args.task == "nq":
examples = load_nq_data(
args.num_examples, split=getattr(args, "nq_split", "validation")
)
elif args.task == "triviaqa":
examples = load_triviaqa_data(args.num_examples)
elif args.task == "nq_tables":
examples = load_nq_tables_data(args.num_examples)
elif args.task == "piqa":
examples = load_piqa_data(args.num_examples)
elif args.task == "hellaswag":
examples = load_hellaswag_data(args.num_examples)
elif args.task == "commonsense_qa":
examples = load_commonsenseqa_data(args.num_examples)
elif args.task == "openbookqa":
examples = load_openbookqa_data(args.num_examples)
elif args.task == "arc_easy":
examples = load_arc_data("ARC-Easy", args.num_examples)
elif args.task == "arc_challenge":
examples = load_arc_data("ARC-Challenge", args.num_examples)
else:
raise ValueError(f"Unsupported task: {args.task}.")
# Stamp each example with its 0-indexed position in the loaded list so
# process_example() can record it. Async writes append in completion order, not
# load order — load_index lets downstream `sorted(records, key=lambda r: r["load_index"])`
# recover the canonical order and gives a true line-level prefix property
# (n=200 records are exactly load_index ∈ [0, 200) of an n=1000 run).
for _idx, _ex in enumerate(examples):
_ex["_load_index"] = _idx
# Build the per-run reproducibility metadata once, after dataset is loaded so
# we know n_loaded. Stamped on every JSONL record by process_example().
run_metadata = _build_run_metadata(args, n_loaded=len(examples))
print(
f"\n[run_metadata] task={run_metadata['task']} split={run_metadata['split']} "
f"n_requested={run_metadata['num_examples_requested']} n_loaded={run_metadata['num_examples_loaded']} "
f"retrieval_top_k={run_metadata['retrieval_top_k']} reader_top_k={run_metadata['reader_top_k']} "
f"reader={run_metadata['reader_model']}"
)
for api_key in ("local_api_status", "text_api_status"):
st = run_metadata.get(api_key)
if st and "_error" not in st:
print(
f"[run_metadata] {api_key}: vec={st.get('total_vectors')} "
f"built_at={st.get('index_built_at')} model={st.get('model')}"
)
elif st:
print(f"[run_metadata] {api_key}: ERROR {st.get('_error')}")
if args.task in ("nq", "triviaqa", "nq_tables"):
for ex in examples:
ex["additional_instructions"] = (
"Answer with as few words as possible. Give only the answer, no explanation."
)
if args.reader_extra_instructions:
for ex in examples:
base = ex.get("additional_instructions") or ""
ex["additional_instructions"] = (
base + "\n\n" + args.reader_extra_instructions
).strip()
if args.reader_few_shot_json:
with open(args.reader_few_shot_json) as _fsf:
_demos = json.load(_fsf)
for ex in examples:
ex["_reader_few_shot"] = _demos
logger.info(
f"Loaded {len(_demos)} few-shot demo(s) from {args.reader_few_shot_json}"
)
# Get model configuration
model_config = get_model_config(args.model)
# Handle OpenRouter API
if args.open_router:
api_base = "https://openrouter.ai/api/v1"
if args.api_key and args.api_key != "dummy":
api_key = args.api_key
else:
api_key = os.getenv("OPENROUTER_API_KEY")
if not api_key or api_key == "dummy":
raise ValueError(
"OpenRouter API key required. Set --api-key or OPENROUTER_API_KEY environment variable."
)
logger.info(f"Using OpenRouter API with model: {args.model}")
model = args.model
elif args.commonstack:
api_base = "https://api.commonstack.ai/v1"
if args.api_key and args.api_key != "dummy":
api_key = args.api_key
else:
api_key = os.getenv("COMMONSTACK_API_KEY")
if not api_key or api_key == "dummy":
raise ValueError(
"Commonstack API key required. Set --api-key or COMMONSTACK_API_KEY environment variable."
)
logger.info(f"Using Commonstack API with model: {args.model}")
model = args.model
else:
# Override with command-line args if provided
# For Gemini, api_base from config is None, so use command-line arg or default
api_base = (
args.api_base
if args.api_base
else (model_config["api_base"] or "http://localhost:8000/v1")
)
api_key = args.api_key if args.api_key else model_config["api_key"]
model = model_config["model"]
# Generate output filename with model name if output is not explicitly set
if not args.output or args.output == "auto":
# Determine mode for filename
if args.url_screenshot:
mode_str = "screenshot"
elif args.url_tiled_screenshot and args.local_wiki:
mode_str = "tiled_screenshot_localwiki"
elif args.url_tiled_screenshot:
mode_str = "tiled_screenshot"
elif args.url_text:
mode_str = f"text_{args.text_source}"
elif args.retrieval_augment:
if args.use_colqwen_retrieval:
mode_str = "vector_colqwen"
else:
mode_str = "vector_jina"
elif args.use_tiled_retrieval:
if args.use_colqwen_retrieval:
mode_str = "tiled_vector_colqwen"
elif args.use_qwen3vl_embedding:
mode_str = "tiled_vector_qwen3vl_embedding"
if args.local_wiki:
mode_str += "_localwiki"
if args.task == "encyclopedic_vqa":
if args.evqa_multimodal_query:
if args.evqa_multimodal_query_text_only:
mode_str += "_multimodal_textonly"
elif args.evqa_multimodal_query_image_only:
mode_str += "_multimodal_imageonly"
else:
mode_str += "_multimodal"
else:
mode_str += "_querycard"
elif args.pixel_query:
mode_str += "_pixelq"
if args.pixel_compress_ratio and args.pixel_compress_ratio > 1:
mode_str += f"_compress{args.pixel_compress_ratio}x"
else:
mode_str = "tiled_vector_jina"
elif args.text_api:
mode_str = "text_api"
elif args.html_dom_lookup:
mode_str = "html_dom_lookup"
elif args.hybrid:
mode_str = "hybrid"
elif args.text_vector:
if args.text_source == "ds-serve":
mode_str = "text_vector_ds_serve"
else:
mode_str = f"text_vector_{args.text_source}_{args.text_embed_preset}"
else:
mode_str = (
"no_retrieval"
if args.task in ("encyclopedic_vqa", "worldvqa")
else "naive"
)
if args.task == "2wiki":
mode_str = "naive"
output_dir = "eval_output"
args.output = get_output_filename(
output_dir=output_dir,
model_name=model,
mode=mode_str,
num_examples=args.num_examples or len(examples),
url_screenshot=args.url_screenshot,
task=args.task,
)
os.makedirs(os.path.dirname(args.output), exist_ok=True)
# Check if output file exists
if os.path.exists(args.output) and os.path.getsize(args.output) > 0:
if not args.force:
print(
f"Error: Output file '{args.output}' already exists and is not empty."
)
print("Use --force to overwrite.")
sys.exit(1)
else:
print(f"Warning: Overwriting existing file '{args.output}'")
# Clear output file
with open(args.output, "w"):
pass
# 2. Initialize retriever (each retriever uses data layer internally)
# Tile width is fixed to 1024 (matches screenshot width)
TILE_WIDTH = 1024
# Set default tiles_dir if not specified
if args.tiles_dir is None:
args.tiles_dir = f"tiles-{TILE_WIDTH}x{args.tile_height}"
# Calculate max_tiles from context length if not specified
# Qwen3-VL: 1024x1024 tile = 1024 image tokens + ~10 overhead = ~1034 tokens
# Scale by tile height ratio
BASE_TOKENS_PER_TILE = 1050 # for 1024x1024
TOKENS_PER_TILE = int(BASE_TOKENS_PER_TILE * args.tile_height / 1024)
RESERVED_TOKENS = 2000 # For question and response
if args.max_tiles is None and (
args.url_tiled_screenshot or args.use_tiled_retrieval
):
available_tokens = args.model_context_length - RESERVED_TOKENS
args.max_tiles = max(1, available_tokens // TOKENS_PER_TILE)
logger.info(
f"Auto-calculated max_tiles: {args.max_tiles} (context={args.model_context_length}, per_tile={TOKENS_PER_TILE}, tile={TILE_WIDTH}x{args.tile_height})"
)
from lib.retrievers import build_retriever
retriever, mode = build_retriever(args, examples, model, api_base, api_key)
# (retriever selection logic moved to simpleqa/retriever_factory.py)
# 3. Initialize LLM client
llm_client = LLMClient(
model=model,
api_base=api_base,
api_key=api_key,
max_tokens=args.max_tokens,
max_context_tokens=args.model_context_length,
timeout=args.timeout,
enable_thinking=(False if args.no_think else None),
force_openai_compat=(args.open_router or args.commonstack),
)
# 3b. Create pixel-compressed encoder for generation if requested
gen_encode_fn = None
if args.pixel_compress_ratio and args.pixel_compress_ratio > 1:
gen_encode_fn = make_compressed_encoder(args.pixel_compress_ratio)
mode += f" (PixelCompress={args.pixel_compress_ratio}x)"
logger.info(f"Generation pixel compression: {args.pixel_compress_ratio}x")
# 3c. Prefetch retrieval results for batch-capable retrievers
if hasattr(retriever, "prefetch"):
print("Prefetching retrieval results (batch API call)...")
await retriever.prefetch(examples)
# 4. Process examples
total_examples = len(examples)
logger.info(
f"Processing {total_examples} examples (Mode: {mode}, Concurrency: {args.max_concurrent})"
)
print(f"\n{'=' * 80}")
print(
f"Starting evaluation: {total_examples} examples with max {args.max_concurrent} concurrent requests"
)
if gen_encode_fn:
print(
f"Pixel compression for generation: {args.pixel_compress_ratio}x (retrieval at original resolution)"
)
print(f"{'=' * 80}\n")
semaphore = asyncio.Semaphore(args.max_concurrent)
# Progress counter (shared dict for async updates)
progress_counter = {"completed": 0, "start_time": time.time()}
tiles_dir = getattr(retriever, "tiles_dir", None) or (
args.tiles_dir if hasattr(args, "tiles_dir") else None
)
if args.react and args.local_api:
tasks = [
process_example_react(
llm_client,
retriever,
ex,
semaphore,
args.output,
progress_counter,
total_examples,
encode_image_fn=gen_encode_fn,
task_name=args.task,
tiles_dir=tiles_dir,
max_turns=args.react_max_turns,
api_url=args.local_api_url,
react_top_k=args.retrieval_top_k,
nprobe=args.nprobe,
prompt_version=args.react_prompt,
)
for ex in examples
]
else:
tasks = [
process_example(
llm_client,
retriever,
ex,
semaphore,
args.output,
progress_counter,
total_examples,
encode_image_fn=gen_encode_fn,
task_name=args.task,
tiles_dir=tiles_dir,
run_metadata=run_metadata,
)
for ex in examples
]
results = await tqdm_asyncio.gather(*tasks)
# Print completion summary
elapsed_time = time.time() - progress_counter["start_time"]
print(f"\n{'=' * 80}")
print(
f"Evaluation completed: {progress_counter['completed']}/{total_examples} examples in {elapsed_time:.1f}s"
)
print(
f"Average time per example: {elapsed_time / max(1, progress_counter['completed']):.2f}s"
)
print(f"{'=' * 80}\n")
# 5. Print statistics
print_statistics(results, args)
def main():
parser = argparse.ArgumentParser(
description="Run SimpleQA evaluation with various retrieval strategies"
)
# Task selection
parser.add_argument(
"--task",
type=str,
default="simpleqa",
choices=[
"simpleqa",
"encyclopedic_vqa",
"worldvqa",
"2wiki",
"simplevqa",
"factualvqa",
"mmsearch",
"webqa",
"multimodalqa",
"nq",
"triviaqa",
"nq_tables",
"piqa",
"hellaswag",
"commonsense_qa",
"openbookqa",
"arc_easy",
"arc_challenge",
],
help="Task/benchmark to run (default: simpleqa)",
)
parser.add_argument(
"--subset",
type=str,
default=None,
help="Dataset subset (e.g., 'val' or 'test' for encyclopedic_vqa)",
)
parser.add_argument(
"--nq-split",
type=str,
default="validation",
choices=["train", "validation"],
help="NQ only: HuggingFace split to stream (default: validation)",
)
parser.add_argument(
"--evqa-dataset-filter",
type=str,
default=None,
choices=["inaturalist", "landmarks"],
help="EVQA only: filter by dataset_name ('inaturalist' or 'landmarks')",
)
parser.add_argument(
"--evqa-question-type-filter",
type=str,
default=None,
help="EVQA only: filter by question_type. Comma-separate to allow multiple "
"(e.g. 'automatic,templated'). Valid values: templated, automatic, multi_answer, 2_hop.",
)
parser.add_argument(
"--worldvqa-language",
type=str,
default=None,
choices=["zh", "non-zh"],
help="WorldVQA only: filter by language ('zh' or 'non-zh')",
)
parser.add_argument(
"--evqa-data-path",
type=str,
default=None,
help="EVQA only: local path to encyclopedic_vqa CSV (default: download from URL)",
)
parser.add_argument(
"--evqa-instruction-override",
type=str,
default=None,
help="EVQA only: replace per-example additional_instructions with this string. "
"Used to standardize prompt across readers for fair comparison.",
)
# Required args
parser.add_argument(
"--model",
type=str,
required=True,
help="Model name (e.g., 'Qwen/Qwen3-VL-4B-Instruct', 'gemini-3-pro-preview')",
)
parser.add_argument(
"--output",
type=str,
default="auto",
help="Output JSONL path (default: auto-generate with model name)",
)
parser.add_argument(
"--force",
action="store_true",
help="Overwrite output file if it exists",
)
# API args
parser.add_argument(
"--api-base", type=str, default="http://localhost:8000/v1", help="API base URL"
)
parser.add_argument("--api-key", type=str, default="dummy", help="API key")
parser.add_argument(
"--open-router",
action="store_true",
help="Use OpenRouter API (https://openrouter.ai). Requires --api-key or OPENROUTER_API_KEY env var.",
)
parser.add_argument(
"--commonstack",
action="store_true",
help="Use Commonstack API (https://api.commonstack.ai). Requires --api-key or COMMONSTACK_API_KEY env var.",
)
# General args
parser.add_argument(
"--num-examples",
type=int,
default=1000,
help="Number of examples (default: 1000 Wikipedia samples)",
)
parser.add_argument(
"--verified",
action="store_true",
help="Use SimpleQA Verified dataset instead of original SimpleQA dataset",
)
parser.add_argument(
"--no-wiki-filter",
action="store_true",
help="Skip Wikipedia URL filter — include all examples (useful for API-based retrieval)",
)
parser.add_argument(
"--max-concurrent", type=int, default=200, help="Max concurrent requests"
)
parser.add_argument(
"--timeout",
type=float,
default=120.0,
help="Request timeout in seconds (increase for tiled mode)",
)
parser.add_argument(
"--screenshot-dir", type=str, default="screenshots", help="Screenshot directory"
)
# Retrieval mode args (mutually exclusive)
parser.add_argument(
"--url-screenshot",
action="store_true",
help="Use ground-truth screenshot for each example",
)
parser.add_argument(
"--max-pixels",
type=int,
default=None,
help="Max pixels for screenshot resize (for --url-screenshot). "
"None=no resize (VLM handles it). "
"Common values: 16777216 (16M, ~16K tokens), 4000000 (4M, ~4K tokens), 1000000 (1M, ~1K tokens)",
)
parser.add_argument(
"--url-tiled-screenshot",
action="store_true",
help="Use ground-truth screenshot split into tiles",
)
parser.add_argument(
"--url-text",
action="store_true",
help="Use text content from URL (crawl/jina/wikipedia)",
)
parser.add_argument(
"--text-source",
type=str,
default="crawl",
choices=["crawl", "jina", "wikipedia", "ds-serve"],
help="Text source for --url-text or --text-vector: crawl (web scraping), jina (Jina Reader API), wikipedia (Wikipedia API), ds-serve (ds-serve API for external text augmentation)",
)
parser.add_argument(
"--url-jina-reader",
action="store_true",
help="[DEPRECATED] Use --url-text --text-source jina instead",
)
parser.add_argument(
"--retrieval-augment", action="store_true", help="Enable vector retrieval"
)
# Text RAG specific
parser.add_argument(
"--max-context-chars",
type=int,
default=None,
help="Max context chars (auto-calculated from --model-context-length if not set)",
)
parser.add_argument(
"--model-context-length",
type=int,
default=65536,
help="Model context length in tokens",
)
parser.add_argument(
"--max-tokens",
type=int,
default=16384,
help="Reader max_tokens (generation budget)",
)
parser.add_argument(
"--no-think",
action="store_true",
help="Disable Qwen3 thinking via chat_template_kwargs.enable_thinking=False",
)
parser.add_argument(
"--text-cache",
type=str,
default="auto",
help="Pre-fetched text JSONL (default: auto-generate based on text-source)",
)
# Vector retrieval specific
parser.add_argument(
"--retrieval-top-k",
type=int,
default=3,
help="Top-k items the retriever fetches per query",
)
parser.add_argument(
"--reader-top-k",
type=int,
default=None,
help=(
"Top-k items the reader actually sees per query. Defaults to --retrieval-top-k. "
"Set lower than --retrieval-top-k to retrieve a larger superset once and downstream-evaluate "
"k=1/2/3 from the same JSONL (full retrieved set is stored in `retrieved_images`). "
"Per root CLAUDE.md the reader_top_k must be in {1, 2, 3}."
),
)
parser.add_argument(
"--jina-api-key",
type=str,
default="jina_de9725ba5457460a9e5b0f89548e6657UN5YStvS5ingpklvVohWgOMiYRxn",
help="Jina API key",
)
parser.add_argument(
"--retrieval-cache", type=str, default=None, help="Embedding cache file"
)
parser.add_argument(
"--single-vector", action="store_true", help="Use single vector mode"
)
# ColQwen2 LEANN retrieval args
parser.add_argument(
"--use-colqwen-retrieval",
action="store_true",
help="Use ColQwen2 LEANN retrieval instead of Jina API",
)
parser.add_argument(
"--colqwen-index-path",
type=str,
default="./indexes/colqwen_screenshots.leann",
help="Path to ColQwen2 LEANN index",
)
parser.add_argument(
"--colqwen-model",
type=str,
default="colqwen2",
choices=["colqwen2", "colqwen2.5", "colpali"],
help="ColQwen2 model name",
)
parser.add_argument(
"--colqwen-search-method",
type=str,
default="ann",
choices=["ann", "exact", "exact-all"],
help="ColQwen2 search method",
)
parser.add_argument(
"--colqwen-first-stage-k",
type=int,
default=500,
help="First stage k for ColQwen2 ANN search",
)
parser.add_argument(
"--rebuild-colqwen-index",
action="store_true",
help="Rebuild ColQwen2 index even if it exists",
)
parser.add_argument(
"--colqwen-recursive",
action="store_true",
help="Recursively search subdirectories when building ColQwen2 index",
)
# Qwen3-VL-Embedding retrieval args
parser.add_argument(
"--use-qwen3vl-embedding",
action="store_true",
help="Use Qwen3-VL-Embedding for tiled retrieval (single vector, 2048 dim)",
)
parser.add_argument(
"--qwen3vl-model",
type=str,
default="Qwen/Qwen3-VL-Embedding-2B",
help="Qwen3-VL-Embedding model name",
)
parser.add_argument(
"--qwen3vl-gpu-ids",
type=str,
default="2,3",
help="Comma-separated GPU IDs for Qwen3-VL-Embedding (default: 2,3, TP=2; use 2,3,6,7 for TP=4 if P2P works)",
)
parser.add_argument(
"--qwen3vl-tp-size",
type=int,
default=1,
help="Tensor parallel size for Qwen3-VL-Embedding (default: 1)",
)
# Tiled vector retrieval args
parser.add_argument(
"--use-tiled-retrieval",
action="store_true",
help="Use tiled vector retrieval (splits images into fixed-size tiles)",
)
parser.add_argument(
"--evqa-multimodal-query",
action="store_true",
help="EVQA only: pass text + image as separate modalities to query embedding (no query card). "
"Uses GLDv2 landmark / iNaturalist image + question text. Requires --use-tiled-retrieval --use-qwen3vl-embedding.",
)
parser.add_argument(
"--evqa-multimodal-query-text-only",
action="store_true",
help="EVQA ablation: with --evqa-multimodal-query, use text-only (no image) for query embedding.",
)
parser.add_argument(
"--evqa-multimodal-query-image-only",
action="store_true",
help="EVQA ablation: with --evqa-multimodal-query, use image-only (no text) for query embedding.",
)
parser.add_argument(
"--evqa-multi-image-query",
action="store_true",
help="EVQA only: use ALL query images per example for retrieval (not just the first). "
"Each image is used for separate multimodal search, scores are aggregated via max. "
"Requires --evqa-multimodal-query --use-tiled-retrieval --use-qwen3vl-embedding.",
)
parser.add_argument(
"--tiles-dir",
type=str,
default=None,
help="Directory to store image tiles (default: tiles-1024x{tile_height})",
)
parser.add_argument(
"--tile-height",
type=int,
default=1024,
help="Tile height in pixels (width is fixed to 1024)",
)
parser.add_argument(
"--tile-overlap", type=int, default=0, help="Overlap between tiles in pixels"
)
parser.add_argument(
"--max-tiles",
type=int,
default=None,
help="Max tiles to use (auto-calculated from --model-context-length if not set)",
)
# Pixel query args
parser.add_argument(
"--pixel-query",
action="store_true",
help="Render queries as images (pixel queries) for retrieval and LLM input. "
"Only works with --use-tiled-retrieval --use-qwen3vl-embedding.",
)
parser.add_argument(
"--pixel-query-dir",
type=str,
default="pixel_queries",
help="Directory to store rendered pixel query images (default: pixel_queries)",
)
# Local wiki-screenshot tiles (pre-rendered, from local kiwix tile store)
parser.add_argument(
"--local-wiki",
action="store_true",
help="Use pre-rendered Wikipedia tiles from local kiwix tile store instead of Selenium.",
)
parser.add_argument(
"--local-wiki-screenshot-dir",
type=str,
default=None,
help="Directory to store raw local-wiki tile downloads (default: screenshots-localwiki). "
"Keeps local-wiki cache separate from regular screenshots.",
)
parser.add_argument(
"--prebuilt-tiles-dir",
type=str,
default=None,
help="Path to a prebuilt tile directory (e.g. tiles-hard-mini/) containing ALL tiles "
"(golden + distractor). Bypasses tile preparation — loads all .png files in the dir.",
)
parser.add_argument(
"--embedding-backend",
type=str,
default="vllm",
choices=["vllm", "hf", "biqwen3"],
help="Backend for Qwen3-VL-Embedding: 'vllm' (default), 'hf' (HF direct GPU), or 'biqwen3' (BiQwen3 + optional PEFT adapter)",
)
parser.add_argument(
"--peft-adapter",
type=str,
default=None,
help="Path to PEFT/LoRA adapter checkpoint (only used with --embedding-backend biqwen3)",
)
# Pixel compression for generation (retrieval stays at original resolution)
parser.add_argument(
"--pixel-compress-ratio",
type=float,
default=None,
help="Pixel compression ratio for generation images (float, ≥1.0). "
"Divides total pixel count by this factor (dimensions by sqrt). "
"E.g. for 1024x1024 tile: 1.5->837x837, 4->512x512, 9->341x341, 16->256x256, 25->205x205. "
"Retrieval is always at original resolution. Default: no compression.",
)
# Local API retrieval
parser.add_argument(
"--local-api",
action="store_true",
help="Use local search API for tile retrieval (localhost:30888/search)",
)
parser.add_argument(
"--local-api-url",
type=str,
default="http://localhost:30888/search",
help="Local search API URL",
)
parser.add_argument(
"--text-api",
action="store_true",
help="Use text search API for text chunk retrieval (text_search_api.py)",
)
parser.add_argument(
"--text-api-url",
type=str,
default="http://localhost:30889/search",
help="Text search API URL (default: http://localhost:30889/search)",
)
parser.add_argument(
"--nprobe",
type=int,
default=None,
help="Override FAISS nprobe for local API search (default: server default)",
)
parser.add_argument(
"--no-query-image",
action="store_true",
help="Suppress attaching the example's query image to the retrieval query. "
"Only affects --local-api (screenshot index): the retriever sends text-only. "
"Reader still receives the query image. Useful for ablations that isolate the "
"visual contribution of the retrieval query (not the reader).",
)
parser.add_argument(
"--query-instruction",
type=str,
default=None,
help="Override query embedding instruction string sent to the search API(s). "
"Applies to --local-api (screenshot, :30888) and --text-api (:30889) and "
"both legs of --hybrid. Default: server-side default "
"('Retrieve images or text relevant to the user's query.' for screenshot, "
"'Retrieve text relevant to the user's query.' for text).",
)
parser.add_argument(
"--reader-extra-instructions",
type=str,
default=None,
help="Extra free-form instructions appended to the reader's user-message "
"additional_instructions (after the task's default, e.g. the short-answer "
"directive for nq/triviaqa/nq_tables). Use for reader-side prompt ablations "
"(e.g. visual-grid steering, few-shot format demos).",
)
parser.add_argument(
"--reader-few-shot-json",
type=str,
default=None,
help="Path to a JSON list of few-shot demos, each {'question','image_path','answer'}. "
"When set, build_messages prepends (Example N, image, Q+A) blocks to every "
"reader user-message. Works across pixel / text / naive modes.",
)
parser.add_argument(
"--lookup-reference-url",
action="store_true",
help="For local-api mode: also look up the ground-truth reference URL in kiwix "
"and append its tiles to the API search results (deduplicated by article ID).",
)
parser.add_argument(
"--reranker",
action="store_true",
help="Use Qwen3-VL-Reranker to rerank retrieved tiles",
)
parser.add_argument(
"--reranker-model",
type=str,
default="Qwen/Qwen3-VL-Reranker-8B",
help="Reranker model name (default: Qwen/Qwen3-VL-Reranker-8B)",
)
parser.add_argument(
"--reranker-gpu-id",
type=int,
default=4,
help="GPU ID for reranker (default: 4)",
)
parser.add_argument(
"--rerank-top-k",
type=int,
default=3,
help="Number of tiles to keep after reranking (default: 3)",
)
parser.add_argument(
"--query-rewrite",
action="store_true",
help="Use LLM to rewrite questions into search queries before retrieval",
)
parser.add_argument(
"--rewrite-model",
type=str,
default=None,
help="Model for query rewriting (default: same as --model)",
)
parser.add_argument(
"--rewrite-api-base",
type=str,
default=None,
help="API base for rewrite model (default: same as --api-base)",
)
parser.add_argument(
"--rewrite-api-key",
type=str,
default=None,
help="API key for rewrite model (default: same as --api-key)",
)
# ReAct multi-turn retrieval
parser.add_argument(
"--react",
action="store_true",
help="Enable ReAct multi-turn retrieval: LLM can issue <search>query</search> to refine results",
)
parser.add_argument(
"--react-max-turns",
type=int,
default=3,
help="Maximum retrieval turns for ReAct (default: 3)",
)
parser.add_argument(
"--react-prompt",
type=str,
default="v1",
choices=["v1", "v2", "multihop"],
help="ReAct prompt version: v1 (original), v2 (improved), or multihop (for multi-hop QA like 2wiki)",
)
# Text vector retrieval args (LEANN-based or ds-serve)
parser.add_argument(
"--text-vector",
action="store_true",
help="Use text vector retrieval with LEANN or ds-serve (if --text-source ds-serve)",
)
parser.add_argument(
"--ds-serve-api-url",
type=str,
default="http://api.ds-serve.org:30888/search",
help="ds-serve API URL (default: http://api.ds-serve.org:30888/search)",
)
parser.add_argument(
"--text-embed-preset",
type=str,
default="qwen",
choices=["qwen", "jina", "contriever"],
help="Embedding preset: qwen (Qwen3-0.6B, default), jina (Jina API), or contriever (lightweight)",
)
parser.add_argument(
"--rebuild-text-index",
action="store_true",
help="Force rebuild text index even if exists",
)
parser.add_argument(
"--embed-batch-size",
type=int,
default=32,
help="Batch size for embedding computation (default: 32, lower if OOM)",
)
parser.add_argument(
"--chunk-size",
type=int,
default=512,
help="Max tokens per chunk for text chunking (default: 512)",
)
parser.add_argument(
"--chunk-overlap",
type=int,
default=128,
help="Overlap tokens between chunks (default: 128)",
)
# Ablation A: OCR wrapper (image retrieve -> OCR -> text to reader)
parser.add_argument(
"--read-as-text-ocr",
action="store_true",
help="Ablation A: OCR retrieved tiles and feed text to reader. "
"Requires an image retrieval mode (--local-api, --use-tiled-retrieval, etc.).",
)
parser.add_argument(
"--ocr-url",
type=str,
default="http://localhost:8202/v1",
help="OCR server base URL (OpenAI-compatible). Default: PaddleOCR-VL at :8202.",
)
parser.add_argument(
"--ocr-model",
type=str,
default="PaddlePaddle/PaddleOCR-VL",
help="OCR model name passed to the chat completions request.",
)
parser.add_argument(
"--ocr-cache",
type=str,
default="ocr_cache/paddleocr_vl.jsonl",
help="JSONL cache for OCR results, keyed by image absolute path.",
)
parser.add_argument(
"--ocr-concurrency",
type=int,
default=16,
help="Max concurrent OCR requests to the server.",
)
# Ablation B: render text chunks as images (text retrieve -> rendered PNG -> VLM)
parser.add_argument(
"--render-as-image",
action="store_true",
help="Ablation B: render each retrieved text chunk as a compact Wikipedia-style image "
"and feed images to the VLM reader. Requires --text-api.",
)
parser.add_argument(
"--render-dir",
type=str,
default="rendered_chunks",
help="Directory for cached rendered chunk images.",
)
# Hybrid retrieval: merge image (LocalAPIRetriever) + text (TextAPIRetriever) hits
# by raw normed-cosine score, take top-K overall, feed mixed-modality to reader.
parser.add_argument(
"--hybrid",
action="store_true",
help="Hybrid retrieval: query both the image search API (--local-api-url) and the "
"text search API (--text-api-url), merge hits by raw score desc, take top "
"--retrieval-top-k overall. Feeds images for image hits and text for text hits "
"to the same VL reader. Mutually exclusive with --local-api / --text-api / "
"--read-as-text-ocr / --render-as-image.",
)
parser.add_argument(
"--html-dom-lookup",
action="store_true",
help="HTML DOM lookup baseline: use text retrieval (--text-api-url) to find chunks, "
"then look up their containing DOM structure in the original HTML from kiwix-serve. "
"Returns structured HTML context (tables, sections) to the reader instead of flat text.",
)
parser.add_argument(
"--llm-verify",
action="store_true",
help="(With --html-dom-lookup) Use an LLM (GPT-4.1-mini) to verify/improve DOM "
"closure extraction. Falls back to heuristic when LLM call fails.",
)
args = parser.parse_args()
# Validate mutually exclusive options
mode_count = sum(
[
args.url_screenshot,
args.url_tiled_screenshot,
args.url_text,
args.url_jina_reader,
args.retrieval_augment,
args.use_tiled_retrieval,
args.text_vector,
]
)
if mode_count > 1:
print(
"Error: Only one mode allowed: --url-screenshot, --url-tiled-screenshot, --url-text, --retrieval-augment, --use-tiled-retrieval, or --text-vector."
)
sys.exit(1)
# Validate retrieval system selection
if args.retrieval_augment and args.use_colqwen_retrieval and args.single_vector:
print(
"Warning: --single-vector is only for Jina API retrieval, ignoring for ColQwen2."
)
# Validate EVQA multimodal ablation flags
if args.evqa_multimodal_query_text_only or args.evqa_multimodal_query_image_only:
if not args.evqa_multimodal_query:
print(
"Error: --evqa-multimodal-query-text-only and --evqa-multimodal-query-image-only require --evqa-multimodal-query."
)
sys.exit(1)
if (
args.evqa_multimodal_query_text_only
and args.evqa_multimodal_query_image_only
):
print(
"Error: --evqa-multimodal-query-text-only and --evqa-multimodal-query-image-only are mutually exclusive."
)
sys.exit(1)
# Set default tiles-dir and screenshot-dir for EVQA (use cached paths)
if args.task == "encyclopedic_vqa":
if args.tiles_dir is None:
args.tiles_dir = "tiles/evqa_localwiki" if args.local_wiki else "tiles/evqa"
if args.use_tiled_retrieval and args.screenshot_dir == "screenshots":
args.screenshot_dir = (
"screenshots/evqa_localwiki" if args.local_wiki else "screenshots/evqa"
)
elif args.tiles_dir is None:
if args.local_wiki:
args.tiles_dir = f"tiles-local-wiki-h{args.tile_height}"
else:
args.tiles_dir = f"tiles-1024x{args.tile_height}"
# Default local-wiki screenshot dir
if args.local_wiki and args.local_wiki_screenshot_dir is None:
args.local_wiki_screenshot_dir = "screenshots-localwiki"
# Auto-calculate max_context_chars if not set
if args.max_context_chars is None:
# Reserve tokens for: system prompt (~200), completion (2048), question (~200), buffer (~500)
reserved_tokens = 3000
available_tokens = args.model_context_length - reserved_tokens
# Conservative estimate: ~2 chars per token (safe for mixed content)
args.max_context_chars = available_tokens * 2
logger.info(
f"Auto-calculated max_context_chars: {args.max_context_chars} (from {args.model_context_length} context tokens)"
)
# Auto-generate text-cache path based on text-source
if args.url_text and args.text_cache == "auto":
cache_dir = "text_cache"
os.makedirs(cache_dir, exist_ok=True)
args.text_cache = os.path.join(
cache_dir, f"text_cache_{args.text_source}.jsonl"
)
logger.info(f"Using text cache: {args.text_cache}")
elif args.text_cache == "auto":
args.text_cache = None # Disable cache for non-text modes
# Lower concurrency for heavy modes (web fetching, screenshots, retrieval, ds-serve, local-api)
if args.local_api and args.max_concurrent > 5:
logger.warning(
f"Lowering max_concurrent from {args.max_concurrent} to 5 for local API stability."
)
args.max_concurrent = 5
elif args.use_tiled_retrieval and args.max_concurrent > 10:
logger.warning(
f"Lowering max_concurrent from {args.max_concurrent} to 10 for tiled retrieval stability."
)
args.max_concurrent = 10
elif (
args.url_screenshot
or args.url_text
or args.url_jina_reader
or args.retrieval_augment
or (args.text_vector and args.text_source == "ds-serve")
) and args.max_concurrent > 20:
reason = (
"ds-serve API"
if (args.text_vector and args.text_source == "ds-serve")
else "image processing"
)
logger.warning(
f"Lowering max_concurrent from {args.max_concurrent} to 20 for {reason} stability."
)
args.max_concurrent = 20
asyncio.run(run_async(args))
if __name__ == "__main__":
main()