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

4357 lines
167 KiB
Python
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Retrieval strategies for SimpleQA evaluation.
This module defines retrieval strategies that work with prepared data:
- NaiveRetriever: No retrieval, just pass query to LLM
- ScreenshotRetriever: Use pre-captured screenshot for the example
- TextRetriever: Use pre-fetched or cached text for the example
- VectorRetriever: Search across all screenshots using vector similarity
"""
import base64
import io
import asyncio
import logging
import os
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
logger = logging.getLogger(__name__)
@dataclass
class RetrievalResult:
"""Result from a retrieval operation."""
# Text content (for text-based retrieval)
text: str | None = None
# Image paths with scores (for vector retrieval)
images: list[tuple[str, float]] = field(default_factory=list)
# Per-image source URLs, aligned with ``images`` when provided.
image_urls: list[str | None] = field(default_factory=list)
# Base64 encoded image (for screenshot)
base64_image: str | None = None
# Source URL
source_url: str | None = None
# Which retrieval type was used
retrieval_type: str = "naive"
# Path to pixel query image used for retrieval embedding (rendered card or raw photo)
pixel_query_path: str | None = None
# Path to raw species/landmark photo for generation (always the original photo,
# never the rendered card). If None, falls back to pixel_query_path in build_messages.
query_image_path: str | None = None
@property
def has_content(self) -> bool:
"""Check if retrieval found any content."""
return bool(self.text or self.images or self.base64_image)
class BaseRetriever(ABC):
"""Base class for retrieval strategies."""
@abstractmethod
async def retrieve(self, query: str, example: dict) -> RetrievalResult:
"""Retrieve relevant content for the query.
Args:
query: The question/query text.
example: The full example dict (may contain metadata, prepared data, etc.).
Returns:
RetrievalResult with retrieved content.
"""
raise NotImplementedError
# EVQA query image data dirs (iNaturalist 2021, Google Landmarks v2)
_INAT2021_DATA_DIR = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
"data",
"inat2021",
)
_LANDMARK_V2_DATA_DIR = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
"data",
"landmark_v2",
)
# Local kiwix tile store (pre-rendered Wikipedia pages)
_WIKI_SCREENSHOT_DIR = "/path/to/project"
_KIWIX_OUTPUT_DIR = "/path/to/data"
_KIWIX_ARTICLES_JSON = "/path/to/data"
_KIWIX_REDIRECTS_JSON = "/path/to/data"
def _lookup_and_copy_local_wiki_tiles(
ex_id: str,
url: str,
tiles_dir: str,
wiki_cache_dir: str,
cut_height: int,
) -> list[str]:
"""Look up a Wikipedia URL in the local kiwix tile store, copy raw tiles, cut into strips.
Args:
ex_id: Example ID (used for output tile naming).
url: Wikipedia URL.
tiles_dir: Directory where cut tile strips are written ({ex_id}_tile_*.png).
wiki_cache_dir: Directory where raw kiwix tile pages are cached ({ex_id}/).
cut_height: Height of each output strip in pixels.
Returns:
Sorted list of cut tile paths.
Raises:
RuntimeError: If kiwix index unavailable, URL not found, or no tiles produced.
"""
import glob as _glob
import shutil
import sys as _sys
from PIL import Image
# Return cached tiles if already cut
existing = sorted(_glob.glob(os.path.join(tiles_dir, f"{ex_id}_tile_*.png")))
if existing:
return existing
if not url or "wikipedia.org" not in url:
raise RuntimeError(f"Not a Wikipedia URL: {url!r}")
if not os.path.isdir(_KIWIX_OUTPUT_DIR) or not os.path.isfile(_KIWIX_ARTICLES_JSON):
raise RuntimeError(f"kiwix tiles unavailable at {_KIWIX_OUTPUT_DIR}")
if _WIKI_SCREENSHOT_DIR not in _sys.path:
_sys.path.insert(0, _WIKI_SCREENSHOT_DIR)
from scripts.build_index import batch_query_by_url as _batch_query
redirects = _KIWIX_REDIRECTS_JSON if os.path.isfile(_KIWIX_REDIRECTS_JSON) else None
results = _batch_query(
_KIWIX_OUTPUT_DIR, [url], _KIWIX_ARTICLES_JSON, redirects_json=redirects
)
result = results.get(url)
if result is None:
raise RuntimeError(f"URL not found in local kiwix: {url}")
# Copy raw kiwix tiles to wiki_cache_dir/{ex_id}/
src_dir = os.path.join(_KIWIX_OUTPUT_DIR, result["tiles_dir"])
article_cache = os.path.join(wiki_cache_dir, str(ex_id))
if not os.path.exists(article_cache):
if not os.path.isdir(src_dir):
raise RuntimeError(f"kiwix tiles dir not on disk: {src_dir}")
shutil.copytree(src_dir, article_cache)
# Cut raw tiles into height=cut_height strips
os.makedirs(tiles_dir, exist_ok=True)
raw_tiles = sorted(
f
for f in os.listdir(article_cache)
if f.endswith(".png") and f.startswith("tile_")
)
if not raw_tiles:
raise RuntimeError(f"No tile PNGs found in {article_cache}")
global_row = 0
for raw_name in raw_tiles:
raw_path = os.path.join(article_cache, raw_name)
if os.path.getsize(raw_path) == 0:
continue
img = Image.open(raw_path)
img.load()
w, h = img.size
y = 0
while y < h:
y2 = min(y + cut_height, h)
strip = img.crop((0, y, w, y2))
strip.save(os.path.join(tiles_dir, f"{ex_id}_tile_{global_row}_0.png"))
strip.close()
global_row += 1
y += cut_height
img.close()
tile_paths = sorted(_glob.glob(os.path.join(tiles_dir, f"{ex_id}_tile_*.png")))
if not tile_paths:
raise RuntimeError(f"No strips cut for {ex_id} (source: {article_cache})")
return tile_paths
def _get_inat_image_path_for_example(example: dict, tiles_dir: str) -> str | None:
"""Get iNaturalist 2021 query image path. dataset_name must be 'inaturalist'."""
inat_ids = example.get("inat_image_ids", [])
if not inat_ids:
return None
cache_dir = os.path.join(os.path.dirname(tiles_dir), "inat_images")
os.makedirs(cache_dir, exist_ok=True)
example_id = example.get("id", "unknown")
local_path = os.path.join(cache_dir, f"{example_id}.jpg")
if os.path.exists(local_path) and os.path.getsize(local_path) > 0:
return local_path
import shutil
id_map = TiledQwen3VLEmbeddingRetriever._load_inat2021_mapping()
for str_id in inat_ids:
try:
img_id = int(str_id)
except ValueError:
continue
file_name = id_map.get(img_id)
if not file_name:
continue
src_path = os.path.join(_INAT2021_DATA_DIR, file_name)
if os.path.exists(src_path) and os.path.getsize(src_path) > 0:
shutil.copy2(src_path, local_path)
return local_path
logger.warning(f"Failed to find iNaturalist image for {example_id}")
return None
def _get_landmark_image_path_for_example(
example: dict, tiles_dir: str, quiet: bool = False
) -> str | None:
"""Get Google Landmarks v2 query image path. dataset_name must be 'landmarks'.
GLDv2 stores images as {split}/{a}/{b}/{c}/{id}.jpg (a,b,c = first 3 chars of id).
Searches train, index, test in order.
"""
ids = example.get("dataset_image_ids_parsed", [])
if not ids:
return None
cache_dir = os.path.join(os.path.dirname(tiles_dir), "landmark_images")
os.makedirs(cache_dir, exist_ok=True)
example_id = example.get("id", "unknown")
local_path = os.path.join(cache_dir, f"{example_id}.jpg")
if os.path.exists(local_path) and os.path.getsize(local_path) > 0:
return local_path
import shutil
data_dir = _LANDMARK_V2_DATA_DIR
for img_id in ids:
if len(img_id) < 3:
continue
# GLDv2 path: {split}/{a}/{b}/{c}/{id}.jpg
subpath = f"{img_id[0]}/{img_id[1]}/{img_id[2]}/{img_id}.jpg"
for split in ("train", "index", "test"):
src_path = os.path.join(data_dir, split, subpath)
if os.path.exists(src_path) and os.path.getsize(src_path) > 0:
shutil.copy2(src_path, local_path)
return local_path
# Fallback: download from train.csv URL (requires data/landmark_v2/train.csv)
# Try each img_id in order; first URL may be 404, others might work
for img_id in ids:
if _try_download_landmark_from_url(example_id, img_id, local_path):
return local_path
if not quiet:
logger.warning(
f"Failed to find Landmark image for {example_id} (data in {data_dir}?)"
)
return None
def _try_download_landmark_from_url(
example_id: str, img_id: str, local_path: str
) -> bool:
"""Try to download landmark image from train.csv URL. Used when GLDv2 TARs unavailable.
Returns True if download succeeded and file is valid, False otherwise.
"""
import urllib.request
train_csv = os.path.join(_LANDMARK_V2_DATA_DIR, "train.csv")
if not os.path.exists(train_csv):
return False
import csv
with open(train_csv) as f:
for row in csv.DictReader(f):
if row.get("id") == img_id:
url = row.get("url", "")
if url:
try:
req = urllib.request.Request(
url, headers={"User-Agent": "PixelRAG-Bot/1.0"}
)
with urllib.request.urlopen(req, timeout=30) as resp:
data = resp.read()
if len(data) >= 1000:
with open(local_path, "wb") as out:
out.write(data)
return True
except Exception as e:
logger.debug(
f"URL download failed for {example_id} (img_id={img_id}): {e}"
)
return False
return False
def _get_query_image_path_for_example(
example: dict, tiles_dir: str, quiet: bool = False
) -> str | None:
"""Get EVQA query image path. Dispatches by dataset_name: inaturalist | landmarks."""
ds = (example.get("dataset_name") or "").lower()
if ds == "inaturalist":
return _get_inat_image_path_for_example(example, tiles_dir)
if ds == "landmarks":
return _get_landmark_image_path_for_example(example, tiles_dir, quiet=quiet)
# Fallback: try inaturalist (backward compat when dataset_name missing)
return _get_inat_image_path_for_example(example, tiles_dir)
def _get_all_inat_image_paths(example: dict, tiles_dir: str) -> list[str]:
"""Get ALL iNaturalist query image paths for an example (not just the first)."""
inat_ids = example.get("inat_image_ids", [])
if not inat_ids:
return []
cache_dir = os.path.join(os.path.dirname(tiles_dir), "inat_images_multi")
os.makedirs(cache_dir, exist_ok=True)
example_id = example.get("id", "unknown")
import shutil
id_map = TiledQwen3VLEmbeddingRetriever._load_inat2021_mapping()
paths = []
for i, str_id in enumerate(inat_ids):
local_path = os.path.join(cache_dir, f"{example_id}_{i}.jpg")
if os.path.exists(local_path) and os.path.getsize(local_path) > 0:
paths.append(local_path)
continue
try:
img_id = int(str_id)
except ValueError:
continue
file_name = id_map.get(img_id)
if not file_name:
continue
src_path = os.path.join(_INAT2021_DATA_DIR, file_name)
if os.path.exists(src_path) and os.path.getsize(src_path) > 0:
shutil.copy2(src_path, local_path)
paths.append(local_path)
return paths
_landmark_url_map_cache: dict[str, str] | None = None
def _load_landmark_url_map() -> dict[str, str]:
"""Load GLDv2 train.csv: img_id -> url. Cached after first call."""
global _landmark_url_map_cache
if _landmark_url_map_cache is not None:
return _landmark_url_map_cache
import csv
train_csv = os.path.join(_LANDMARK_V2_DATA_DIR, "train.csv")
if not os.path.exists(train_csv):
_landmark_url_map_cache = {}
return _landmark_url_map_cache
url_map = {}
with open(train_csv, encoding="utf-8") as f:
for row in csv.DictReader(f):
img_id = row.get("id", "").strip()
url = row.get("url", "").strip()
if img_id and url:
url_map[img_id] = url
_landmark_url_map_cache = url_map
logger.info(f"Loaded landmark URL map: {len(url_map)} entries")
return url_map
def _download_landmark_image_by_id(img_id: str, local_path: str) -> bool:
"""Download a landmark image by its GLDv2 ID. Returns True on success."""
import urllib.request
url_map = _load_landmark_url_map()
url = url_map.get(img_id)
if not url:
return False
try:
req = urllib.request.Request(url, headers={"User-Agent": "PixelRAG-Bot/1.0"})
with urllib.request.urlopen(req, timeout=30) as resp:
data = resp.read()
if len(data) >= 1000:
with open(local_path, "wb") as out:
out.write(data)
return True
except Exception as e:
logger.debug(f"Download failed for landmark {img_id}: {e}")
return False
def _get_all_landmark_image_paths(example: dict, tiles_dir: str) -> list[str]:
"""Get ALL Google Landmarks query image paths for an example (not just the first)."""
ids = example.get("dataset_image_ids_parsed", [])
if not ids:
return []
cache_dir = os.path.join(os.path.dirname(tiles_dir), "landmark_images_multi")
os.makedirs(cache_dir, exist_ok=True)
example_id = example.get("id", "unknown")
import shutil
data_dir = _LANDMARK_V2_DATA_DIR
paths = []
for i, img_id in enumerate(ids):
local_path = os.path.join(cache_dir, f"{example_id}_{i}.jpg")
if os.path.exists(local_path) and os.path.getsize(local_path) > 0:
paths.append(local_path)
continue
if len(img_id) < 3:
continue
subpath = f"{img_id[0]}/{img_id[1]}/{img_id[2]}/{img_id}.jpg"
found = False
for split in ("train", "index", "test"):
src_path = os.path.join(data_dir, split, subpath)
if os.path.exists(src_path) and os.path.getsize(src_path) > 0:
shutil.copy2(src_path, local_path)
paths.append(local_path)
found = True
break
if not found:
if _download_landmark_image_by_id(img_id, local_path):
paths.append(local_path)
return paths
def _get_all_query_image_paths(example: dict, tiles_dir: str) -> list[str]:
"""Get ALL query image paths for an EVQA example (all available images, not just the first).
Falls back to the single ``query_image_path`` / ``_get_query_image_path_for_example``
when the multi-image helpers return nothing (e.g. ``dataset_image_ids_parsed`` lives
inside ``original_data`` rather than at top level).
"""
ds = (example.get("dataset_name") or "").lower()
if ds not in ("inaturalist", "landmarks"):
od = example.get("original_data", {})
if isinstance(od, str):
import ast
try:
od = ast.literal_eval(od)
except Exception:
od = {}
ds = (od.get("dataset_name") or "").lower()
if ds == "inaturalist":
paths = _get_all_inat_image_paths(example, tiles_dir)
elif ds == "landmarks":
paths = _get_all_landmark_image_paths(example, tiles_dir)
else:
paths = _get_all_inat_image_paths(example, tiles_dir)
if not paths:
single = example.get("query_image_path") or _get_query_image_path_for_example(
example, tiles_dir, quiet=True
)
if single and os.path.exists(single):
paths = [single]
return paths
class NaiveRetriever(BaseRetriever):
"""No retrieval - returns empty result, LLM answers from its own knowledge."""
async def retrieve(self, query: str, example: dict) -> RetrievalResult:
return RetrievalResult(retrieval_type="naive")
class EVQANoRetrievalRetriever(BaseRetriever):
"""EVQA without retrieval: query + iNaturalist image only, no Wikipedia tiles.
Used to test VLM's ability to answer from the species image alone.
"""
def __init__(self, tiles_dir: str = "tiles/evqa"):
self.tiles_dir = tiles_dir
async def retrieve(self, query: str, example: dict) -> RetrievalResult:
inat_image_path = _get_query_image_path_for_example(example, self.tiles_dir)
return RetrievalResult(
images=[],
retrieval_type="evqa_no_retrieval_multimodal",
pixel_query_path=inat_image_path,
query_image_path=inat_image_path,
)
def _save_task_query_image(
example: dict, task_name: str, base_dir: str = "tiles"
) -> str | None:
"""Save query image from any task to disk. Returns path or None.
Images saved to {base_dir}/{task_name}_images/{example_id}.png
Works with PIL images, base64 strings, or dict with 'bytes' key.
"""
img = example.get("image")
if img is None:
return None
example_id = example.get("id", "unknown")
save_dir = os.path.join(base_dir, f"{task_name}_images")
os.makedirs(save_dir, exist_ok=True)
out_path = os.path.join(save_dir, f"{example_id}.png")
if os.path.exists(out_path) and os.path.getsize(out_path) > 0:
return out_path
try:
if hasattr(img, "save"):
img.save(out_path, format="PNG")
return out_path
if isinstance(img, str):
raw = (
img.split(",", 1)[1] if img.startswith("data:") and "," in img else img
)
data = base64.b64decode(raw)
with open(out_path, "wb") as f:
f.write(data)
return out_path
if isinstance(img, dict) and "bytes" in img:
b = img["bytes"]
if b:
with open(out_path, "wb") as f:
f.write(b)
return out_path
except Exception as e:
logger.warning(f"Failed to save {task_name} image for {example_id}: {e}")
return None
def _save_worldvqa_query_image(example: dict, base_dir: str = "tiles") -> str | None:
"""Save WorldVQA query image to disk. Returns path or None.
Images saved to {base_dir}/worldvqa_images/{example_id}.png
"""
img = example.get("image")
if img is None:
return None
example_id = example.get("id", "unknown")
save_dir = os.path.join(base_dir, "worldvqa_images")
os.makedirs(save_dir, exist_ok=True)
out_path = os.path.join(save_dir, f"{example_id}.png")
try:
if hasattr(img, "save"):
img.save(out_path, format="PNG")
return out_path
if isinstance(img, str):
raw = (
img.split(",", 1)[1] if img.startswith("data:") and "," in img else img
)
data = base64.b64decode(raw)
ext = ".png" if data[:8] == b"\x89PNG\r\n\x1a\n" else ".jpg"
out_path = os.path.join(save_dir, f"{example_id}{ext}")
with open(out_path, "wb") as f:
f.write(data)
return out_path
if isinstance(img, dict) and "bytes" in img:
b = img["bytes"]
if b:
ext = ".png" if b[:8] == b"\x89PNG\r\n\x1a\n" else ".jpg"
out_path = os.path.join(save_dir, f"{example_id}{ext}")
with open(out_path, "wb") as f:
f.write(b)
return out_path
except Exception as e:
logger.warning(f"Failed to save WorldVQA image for {example_id}: {e}")
return None
def _worldvqa_image_to_base64(img) -> str | None:
"""Convert WorldVQA image (PIL, base64 str, or dict) to base64 string."""
if img is None:
return None
if isinstance(img, str):
if img.startswith("data:"):
if "," in img:
return img.split(",", 1)[1]
return img
if hasattr(img, "save"):
buf = io.BytesIO()
img.save(buf, format="PNG")
return base64.b64encode(buf.getvalue()).decode()
if isinstance(img, dict) and "bytes" in img:
b = img["bytes"]
return base64.b64encode(b).decode() if b else None
return None
class WorldVQANoRetrievalRetriever(BaseRetriever):
"""WorldVQA without retrieval: query + image from dataset only.
WorldVQA images are embedded in the HuggingFace dataset (PIL or base64).
"""
async def retrieve(self, query: str, example: dict) -> RetrievalResult:
img = example.get("image")
base64_img = _worldvqa_image_to_base64(img)
return RetrievalResult(
base64_image=base64_img,
retrieval_type="worldvqa_no_retrieval",
)
class ScreenshotRetriever(BaseRetriever):
"""Use screenshot that was prepared in data layer.
Expects screenshot to be captured beforehand. This retriever just
loads and encodes the existing screenshot.
For ground truth evaluation, uses encode_screenshot_for_vlm_async which
does NOT apply max_height limit. You can control max_pixels to study
the effect of resize on VLM performance.
Args:
screenshot_dir: Directory containing screenshots.
max_pixels: Maximum pixels before resize. If None, no resize (89M limit).
Common values:
- None: No resize (let VLM handle it)
- 16_777_216 (16M): Qwen3-VL default, ~16K tokens
- 4_000_000 (4M): ~4K tokens
- 1_000_000 (1M): ~1K tokens
"""
def __init__(
self, screenshot_dir: str = "screenshots", max_pixels: int | None = None
):
self.screenshot_dir = screenshot_dir
self.max_pixels = max_pixels
async def retrieve(self, query: str, example: dict) -> RetrievalResult:
from .simpleqa_data import (
capture_screenshot_async,
encode_screenshot_for_vlm_async,
extract_url_from_metadata,
)
# Get or capture screenshot
screenshot_path = await capture_screenshot_async(example, self.screenshot_dir)
if not screenshot_path:
return RetrievalResult(
retrieval_type="screenshot",
source_url=extract_url_from_metadata(example),
)
# Encode to base64 with configurable max_pixels
base64_image = await encode_screenshot_for_vlm_async(
screenshot_path, max_pixels=self.max_pixels
)
return RetrievalResult(
base64_image=base64_image,
source_url=extract_url_from_metadata(example),
retrieval_type="screenshot",
)
class TiledScreenshotRetriever(BaseRetriever):
"""Use tiled screenshot from ground truth URL.
Captures screenshot for the example's URL, splits it into tiles,
and returns tiles. This is ground truth (not vector search).
Args:
max_tiles: Maximum number of tiles to return. If None, returns all tiles.
For context-aware limiting, calculate based on model context length.
Rough estimate: max_tiles = (context_length - 2000) / tokens_per_tile
where tokens_per_tile ≈ 1500-2000 for most VLMs.
"""
def __init__(
self,
screenshot_dir: str = "screenshots",
tiles_dir: str = "tiles",
tile_size: int = 512,
overlap: int = 0,
max_tiles: int | None = None,
):
self.screenshot_dir = screenshot_dir
self.tiles_dir = tiles_dir
self.tile_size = tile_size
self.overlap = overlap
self.max_tiles = max_tiles
os.makedirs(tiles_dir, exist_ok=True)
async def retrieve(self, query: str, example: dict) -> RetrievalResult:
from .simpleqa_data import (
capture_screenshot_async,
encode_screenshot_async,
extract_url_from_metadata,
split_image_to_tiles,
)
# Get or capture screenshot
screenshot_path = await capture_screenshot_async(example, self.screenshot_dir)
if not screenshot_path:
return RetrievalResult(
retrieval_type="tiled_screenshot",
source_url=extract_url_from_metadata(example),
)
# Split into tiles
example_id = example.get("id", "unknown")
example_tiles_dir = os.path.join(self.tiles_dir, example_id)
tile_paths = split_image_to_tiles(
screenshot_path,
example_tiles_dir,
tile_size=self.tile_size,
overlap=self.overlap,
)
if not tile_paths:
# Fall back to full screenshot
base64_image = await encode_screenshot_async(screenshot_path)
return RetrievalResult(
base64_image=base64_image,
source_url=extract_url_from_metadata(example),
retrieval_type="tiled_screenshot",
)
# Limit tiles if max_tiles is set
if self.max_tiles is not None and len(tile_paths) > self.max_tiles:
logger.info(f"Limiting tiles from {len(tile_paths)} to {self.max_tiles}")
tile_paths = tile_paths[: self.max_tiles]
# Return tiles as images list (path, score=1.0 for ground truth)
images = [(path, 1.0) for path in tile_paths]
return RetrievalResult(
images=images,
source_url=extract_url_from_metadata(example),
retrieval_type="tiled_screenshot",
)
class LocalWikiTiledScreenshotRetriever(BaseRetriever):
"""Ground-truth tiled retriever using pre-rendered Wikipedia tiles from local kiwix.
For each example, looks up the Wikipedia URL in the local kiwix tile store,
copies raw tiles to a local cache, cuts into tile_height strips, and passes
all tiles to the VLM as context. No Selenium, no SSH.
Args:
tiles_dir: Directory for cut tile strips (output).
wiki_cache_dir: Directory for raw kiwix tile copies.
tile_height: Height of each strip in pixels (default 1024).
max_tiles: Maximum tiles to pass to VLM (None = all).
"""
def __init__(
self,
tiles_dir: str = "tiles-local-wiki",
wiki_cache_dir: str = "screenshots-localwiki",
tile_height: int = 1024,
max_tiles: int | None = None,
):
self.tiles_dir = tiles_dir
self.wiki_cache_dir = wiki_cache_dir
self.tile_height = tile_height
self.max_tiles = max_tiles
os.makedirs(tiles_dir, exist_ok=True)
os.makedirs(wiki_cache_dir, exist_ok=True)
async def retrieve(self, query: str, example: dict) -> RetrievalResult:
from .simpleqa_data import extract_url_from_metadata
ex_id = example.get("id", "unknown")
url = extract_url_from_metadata(example) or ""
loop = asyncio.get_event_loop()
try:
tile_paths = await loop.run_in_executor(
None,
lambda: _lookup_and_copy_local_wiki_tiles(
ex_id, url, self.tiles_dir, self.wiki_cache_dir, self.tile_height
),
)
except RuntimeError as e:
logger.error(f"local-wiki [{ex_id}]: {e}")
return RetrievalResult(retrieval_type="local_wiki_tiled", source_url=url)
if self.max_tiles is not None and len(tile_paths) > self.max_tiles:
tile_paths = tile_paths[: self.max_tiles]
images = [(path, 1.0) for path in tile_paths]
return RetrievalResult(
images=images,
source_url=url,
retrieval_type="local_wiki_tiled",
)
class TextRetriever(BaseRetriever):
"""Use text content fetched from URL.
Can use pre-cached text or fetch on demand.
"""
def __init__(
self,
max_chars: int = 50000,
text_cache: dict | None = None,
cache_path: str | None = None,
):
self.max_chars = max_chars
self.text_cache = text_cache
self.cache_path = cache_path
self._cache_lock = asyncio.Lock()
async def _save_to_cache(self, example_id: str, text: str, url: str):
"""Append result to cache file."""
if not self.cache_path:
return
try:
import json
async with self._cache_lock:
with open(self.cache_path, "a") as f:
cache_entry = {"id": example_id, "text": text, "url": url}
f.write(json.dumps(cache_entry) + "\n")
except Exception as e:
logger.warning(f"Failed to save to cache: {e}")
async def retrieve(self, query: str, example: dict) -> RetrievalResult:
from .simpleqa_data import fetch_text_async
example_id = example.get("id", "")
was_cached = self.text_cache and example_id in self.text_cache
text, source_url = await fetch_text_async(
example, self.max_chars, self.text_cache
)
# Save to cache if not already cached
if not was_cached and text and source_url:
await self._save_to_cache(example_id, text, source_url)
return RetrievalResult(
text=text, source_url=source_url, retrieval_type="text_rag"
)
class JinaReaderRetriever(BaseRetriever):
"""Use Jina Reader API to fetch clean markdown text from URL.
Jina Reader (r.jina.ai) converts any URL to LLM-friendly markdown text.
"""
def __init__(
self,
max_chars: int = 50000,
api_key: str | None = None,
text_cache: dict | None = None,
cache_path: str | None = None,
):
self.max_chars = max_chars
self.api_key = api_key
self.text_cache = text_cache
self.cache_path = cache_path
self._cache_lock = asyncio.Lock()
async def _save_to_cache(self, example_id: str, text: str, url: str):
"""Append result to cache file."""
if not self.cache_path:
return
try:
import json
async with self._cache_lock:
with open(self.cache_path, "a") as f:
cache_entry = {"id": example_id, "text": text, "url": url}
f.write(json.dumps(cache_entry) + "\n")
except Exception as e:
logger.warning(f"Failed to save to cache: {e}")
async def retrieve(self, query: str, example: dict) -> RetrievalResult:
import aiohttp
import asyncio
from .simpleqa_data import extract_url_from_metadata
# Check cache first
example_id = example.get("id", "")
if self.text_cache and example_id in self.text_cache:
cached = self.text_cache[example_id]
text = cached.get("text", "")
source_url = cached.get("url", "")
if text:
if len(text) > self.max_chars:
text = text[: self.max_chars] + "\n\n[Content truncated...]"
return RetrievalResult(
text=text, source_url=source_url, retrieval_type="jina_reader"
)
target_url = extract_url_from_metadata(example)
if not target_url:
return RetrievalResult(
text="No URL found in metadata.", retrieval_type="jina_reader"
)
# Use Jina Reader API with retry logic
reader_url = f"https://r.jina.ai/{target_url}"
headers = {}
if self.api_key:
headers["Authorization"] = f"Bearer {self.api_key}"
max_retries = 5
for attempt in range(max_retries):
try:
async with aiohttp.ClientSession() as session:
async with session.get(
reader_url,
headers=headers,
timeout=aiohttp.ClientTimeout(total=60),
) as response:
# Handle rate limiting (429) with exponential backoff
if response.status == 429:
if attempt < max_retries - 1:
wait_time = min(2**attempt * 2, 30) # Max 30 seconds
logger.warning(
f"Rate limited (429) for {target_url}, waiting {wait_time}s before retry ({attempt + 1}/{max_retries})"
)
await asyncio.sleep(wait_time)
continue
else:
error_msg = f"Jina Reader API rate limited (429) after {max_retries} retries"
logger.error(f"{error_msg} for {target_url}")
return RetrievalResult(
text=error_msg,
source_url=target_url,
retrieval_type="jina_reader",
)
# Handle server errors (5xx) with retry
if 500 <= response.status < 600:
if attempt < max_retries - 1:
wait_time = min(2**attempt, 10) # Max 10 seconds
logger.warning(
f"Server error ({response.status}) for {target_url}, waiting {wait_time}s before retry ({attempt + 1}/{max_retries})"
)
await asyncio.sleep(wait_time)
continue
else:
error_msg = (
f"Jina Reader API server error: {response.status}"
)
logger.error(f"{error_msg} for {target_url}")
return RetrievalResult(
text=error_msg,
source_url=target_url,
retrieval_type="jina_reader",
)
# Handle client errors (4xx) - don't retry for most
if response.status == 200:
text = await response.text()
# Save to cache before truncation
await self._save_to_cache(example_id, text, target_url)
# Truncate if too long
if len(text) > self.max_chars:
text = (
text[: self.max_chars]
+ "\n\n[Content truncated...]"
)
return RetrievalResult(
text=text,
source_url=target_url,
retrieval_type="jina_reader",
)
else:
# Other 4xx errors (403, 404, etc.) - don't retry
error_msg = f"Jina Reader API error: {response.status}"
logger.warning(f"{error_msg} for {target_url}")
return RetrievalResult(
text=error_msg,
source_url=target_url,
retrieval_type="jina_reader",
)
except asyncio.TimeoutError:
if attempt < max_retries - 1:
wait_time = min(2**attempt, 10) # Max 10 seconds
logger.warning(
f"Timeout for {target_url}, waiting {wait_time}s before retry ({attempt + 1}/{max_retries})"
)
await asyncio.sleep(wait_time)
continue
else:
error_msg = f"Jina Reader fetch timeout after {max_retries} retries"
logger.error(f"{error_msg} for {target_url}")
return RetrievalResult(
text=error_msg,
source_url=target_url,
retrieval_type="jina_reader",
)
except aiohttp.ClientError as e:
if attempt < max_retries - 1:
wait_time = min(2**attempt, 10) # Max 10 seconds
logger.warning(
f"Client error for {target_url}: {e}, waiting {wait_time}s before retry ({attempt + 1}/{max_retries})"
)
await asyncio.sleep(wait_time)
continue
else:
error_msg = f"Jina Reader fetch failed: {e}"
logger.error(f"{error_msg} for {target_url}")
return RetrievalResult(
text=error_msg,
source_url=target_url,
retrieval_type="jina_reader",
)
except Exception as e:
error_msg = f"Jina Reader fetch failed: {e}"
logger.error(f"{error_msg} for {target_url}")
return RetrievalResult(
text=error_msg, source_url=target_url, retrieval_type="jina_reader"
)
# Should not reach here, but just in case
error_msg = f"Jina Reader fetch failed after {max_retries} retries"
return RetrievalResult(
text=error_msg, source_url=target_url, retrieval_type="jina_reader"
)
class WikipediaAPIRetriever(BaseRetriever):
"""Use Wikipedia API to fetch clean article text.
Extracts Wikipedia page title from URL and fetches content via API.
Much cleaner and faster than web scraping.
"""
def __init__(
self,
max_chars: int = 50000,
text_cache: dict | None = None,
cache_path: str | None = None,
):
self.max_chars = max_chars
self.text_cache = text_cache
self.cache_path = cache_path
self._cache_lock = asyncio.Lock()
async def _save_to_cache(self, example_id: str, text: str, url: str):
"""Append result to cache file."""
if not self.cache_path:
return
try:
import json
async with self._cache_lock:
with open(self.cache_path, "a") as f:
cache_entry = {"id": example_id, "text": text, "url": url}
f.write(json.dumps(cache_entry) + "\n")
except Exception as e:
logger.warning(f"Failed to save to cache: {e}")
def _extract_wiki_title(self, url: str) -> str | None:
"""Extract Wikipedia page title from URL."""
import re
from urllib.parse import unquote
# Match patterns like:
# https://en.wikipedia.org/wiki/Python_(programming_language)
# https://zh.wikipedia.org/wiki/Artificial_intelligence
pattern = r"https?://[a-z]{2,3}\.wikipedia\.org/wiki/(.+?)(?:#.*)?$"
match = re.match(pattern, url)
if match:
title = unquote(match.group(1))
# Replace underscores with spaces
title = title.replace("_", " ")
return title
return None
def _get_wiki_lang(self, url: str) -> str:
"""Extract Wikipedia language code from URL."""
import re
match = re.match(r"https?://([a-z]{2,3})\.wikipedia\.org", url)
return match.group(1) if match else "en"
def _html_to_text(self, html: str) -> str:
"""Convert Wikipedia HTML to plain text, preserving table content."""
import re
from bs4 import BeautifulSoup
soup = BeautifulSoup(html, "html.parser")
# Remove unwanted elements
for tag in soup.find_all(["script", "style", "link", "meta"]):
tag.decompose()
# Remove edit section links
for tag in soup.find_all("span", class_="mw-editsection"):
tag.decompose()
# Remove reference numbers [1], [2], etc.
for tag in soup.find_all("sup", class_="reference"):
tag.decompose()
# Get text
text = soup.get_text(separator="\n", strip=True)
# Clean up excessive newlines
text = re.sub(r"\n{3,}", "\n\n", text)
return text
def _parse_infobox(self, wikitext: str) -> str:
"""Parse infobox from wikitext and convert to plain text."""
import re
# Find infobox start
start = wikitext.find("{{Infobox")
if start == -1:
start = wikitext.find("{{infobox")
if start == -1:
return ""
# Count braces to find matching end
depth = 0
end = start
for i in range(start, len(wikitext)):
if wikitext[i : i + 2] == "{{":
depth += 1
elif wikitext[i : i + 2] == "}}":
depth -= 1
if depth == 0:
end = i + 2
break
infobox_raw = wikitext[start:end]
# Parse fields
lines = []
for match in re.finditer(
r"\|\s*([^=|]+?)\s*=\s*([^|]*?)(?=\n\s*\||\}\})", infobox_raw, re.DOTALL
):
key = match.group(1).strip()
value = match.group(2).strip()
# Skip image-related fields
if key.lower() in (
"image",
"caption",
"alt",
"width",
"height",
"image_size",
"imagesize",
):
continue
# Clean up wikitext markup
value = re.sub(
r"\{\{[^}|]*\|([^}]*)\}\}", r"\1", value
) # {{template|value}} -> value
value = re.sub(
r"\[\[([^|\]]*\|)?([^\]]*)\]\]", r"\2", value
) # [[link|text]] -> text
value = re.sub(r"'''?", "", value) # bold/italic
value = re.sub(r"<[^>]+>", "", value) # HTML tags
value = re.sub(r"\{\{[^}]*\}\}", "", value) # remaining templates
value = " ".join(value.split()) # normalize whitespace
if value:
lines.append(f"{key}: {value}")
return "\n".join(lines)
async def retrieve(self, query: str, example: dict) -> RetrievalResult:
import aiohttp
from .simpleqa_data import extract_url_from_metadata
# Check cache first
example_id = example.get("id", "")
if self.text_cache and example_id in self.text_cache:
cached = self.text_cache[example_id]
text = cached.get("text", "")
source_url = cached.get("url", "")
if text:
if len(text) > self.max_chars:
text = text[: self.max_chars] + "\n\n[Content truncated...]"
return RetrievalResult(
text=text, source_url=source_url, retrieval_type="wikipedia_api"
)
target_url = extract_url_from_metadata(example)
if not target_url:
return RetrievalResult(
text="No URL found in metadata.", retrieval_type="wikipedia_api"
)
# Check if it's a Wikipedia URL
if "wikipedia.org" not in target_url.lower():
return RetrievalResult(
text=f"URL is not a Wikipedia page: {target_url}",
source_url=target_url,
retrieval_type="wikipedia_api",
)
title = self._extract_wiki_title(target_url)
if not title:
return RetrievalResult(
text=f"Could not extract Wikipedia title from: {target_url}",
source_url=target_url,
retrieval_type="wikipedia_api",
)
lang = self._get_wiki_lang(target_url)
api_url = f"https://{lang}.wikipedia.org/w/api.php"
headers = {
"User-Agent": "SimpleQA-Evaluation/1.0 (https://github.com/example; contact@example.com)"
}
try:
async with aiohttp.ClientSession() as session:
# Use action=parse to get full HTML (includes tables)
parse_params = {
"action": "parse",
"page": title,
"prop": "text",
"format": "json",
"redirects": "1",
}
timeout = aiohttp.ClientTimeout(total=30)
async with session.get(
api_url, params=parse_params, headers=headers, timeout=timeout
) as resp:
if resp.status != 200:
error_msg = f"Wikipedia API error: {resp.status}"
logger.warning(f"{error_msg} for {target_url}")
return RetrievalResult(
text=error_msg,
source_url=target_url,
retrieval_type="wikipedia_api",
)
data = await resp.json()
# Check for error
if "error" in data:
error_msg = data["error"].get("info", "Unknown error")
return RetrievalResult(
text=f"Wikipedia page not found: {title}",
source_url=target_url,
retrieval_type="wikipedia_api",
)
html = data.get("parse", {}).get("text", {}).get("*", "")
if not html:
return RetrievalResult(
text=f"No content found for Wikipedia page: {title}",
source_url=target_url,
retrieval_type="wikipedia_api",
)
# Parse HTML to text (includes tables)
text = self._html_to_text(html)
# Save to cache before truncation
await self._save_to_cache(example_id, text, target_url)
# Truncate if too long
if len(text) > self.max_chars:
text = text[: self.max_chars] + "\n\n[Content truncated...]"
return RetrievalResult(
text=text, source_url=target_url, retrieval_type="wikipedia_api"
)
except Exception as e:
error_msg = f"Wikipedia API fetch failed: {e}"
logger.warning(f"{error_msg} for {target_url}")
return RetrievalResult(
text=error_msg, source_url=target_url, retrieval_type="wikipedia_api"
)
class VectorRetriever(BaseRetriever):
"""Retrieve similar screenshots using vector similarity search.
Uses Jina API for embedding and retrieval across dataset screenshots only.
"""
def __init__(
self,
api_key: str,
screenshot_dir: str = "screenshots",
cache_path: str | None = None,
use_multivector: bool = True,
top_k: int = 3,
examples: list[dict] | None = None,
):
self.top_k = top_k
self.screenshot_dir = screenshot_dir
self.examples = examples or []
os.makedirs(screenshot_dir, exist_ok=True)
# Prepare missing screenshots and get file paths
screenshot_paths = self._prepare_screenshots()
# Import retrieval system
try:
from scripts.jina_retrieval import JinaAPIRetrievalSystem
except ImportError:
try:
from jina_retrieval import JinaAPIRetrievalSystem
except ImportError:
raise ImportError("JinaAPIRetrievalSystem not available")
vector_type = "single vector" if not use_multivector else "multivector"
logger.info(f"Initializing VectorRetriever with {vector_type} mode")
self.retrieval_system = JinaAPIRetrievalSystem(
api_key=api_key,
use_multivector=use_multivector,
device="cpu", # Use CPU to avoid OOM when VLM is on GPU
)
# Only embed screenshots for current dataset
self.retrieval_system.embed_images(
file_paths=screenshot_paths, cache_path=cache_path
)
logger.info(
f"VectorRetriever ready with {len(self.retrieval_system.image_paths)} images"
)
def _prepare_screenshots(self) -> list[str]:
"""Prepare screenshots for dataset and return list of paths."""
from .simpleqa_data import capture_screenshot_for_example
screenshot_paths = []
missing = []
for ex in self.examples:
screenshot_path = os.path.join(
self.screenshot_dir, f"{ex['id']}_fullhd.png"
)
screenshot_paths.append(screenshot_path)
if (
not os.path.exists(screenshot_path)
or os.path.getsize(screenshot_path) == 0
):
missing.append(ex)
if missing:
logger.info(
f"Found {len(missing)} missing screenshots out of {len(self.examples)} total examples"
)
logger.info(f"Preparing {len(missing)} missing screenshots...")
# Use a more robust approach: continue even if some screenshots fail
success_count = 0
for ex in missing:
try:
capture_screenshot_for_example(ex, self.screenshot_dir)
success_count += 1
except Exception as e:
logger.warning(
f"Failed to capture screenshot for {ex.get('id', 'unknown')}: {e}"
)
# Continue with next screenshot instead of failing completely
logger.info(
f"Screenshots prepared: {success_count}/{len(missing)} successful"
)
else:
logger.info(
f"All {len(self.examples)} screenshots already exist, skipping preparation"
)
# Return only existing screenshots
return [
p for p in screenshot_paths if os.path.exists(p) and os.path.getsize(p) > 0
]
async def retrieve(self, query: str, example: dict) -> RetrievalResult:
loop = asyncio.get_event_loop()
try:
results = await loop.run_in_executor(
None, self.retrieval_system.retrieve, query, self.top_k
)
if results:
return RetrievalResult(images=results, retrieval_type="vector")
except Exception as e:
logger.warning(f"Vector retrieval failed: {e}")
return RetrievalResult(retrieval_type="vector")
class ColQwenVectorRetriever(BaseRetriever):
"""Retrieve similar screenshots using ColQwen2 LEANN multi-vector retrieval."""
def __init__(
self,
index_path: str,
screenshot_dir: str = "screenshots",
model_name: str = "colqwen2",
search_method: str = "ann",
first_stage_k: int = 500,
rebuild_index: bool = False,
recursive: bool = False,
top_k: int = 3,
examples: list[dict] | None = None,
prepare_screenshots: bool = False, # ColQwen2 doesn't need to prepare specific screenshots
):
self.top_k = top_k
self.screenshot_dir = screenshot_dir
self.examples = examples or []
os.makedirs(screenshot_dir, exist_ok=True)
# Build list of image paths for the specific examples (only Wikipedia samples)
image_paths = self._get_example_image_paths()
if image_paths:
logger.info(
f"ColQwen2 will retrieve from {len(image_paths)} images for {len(self.examples)} examples"
)
else:
logger.warning(
f"No images found for examples, falling back to all images in: {screenshot_dir}"
)
# Import ColQwen2 retrieval system
import sys
from pathlib import Path
# Add scripts directory to path for import
scripts_dir = Path(__file__).parent.parent
if str(scripts_dir) not in sys.path:
sys.path.insert(0, str(scripts_dir))
try:
from colqwen_leann_retrieval import ColQwenLEANNRetrievalSystem
except ImportError:
try:
from scripts.colqwen_leann_retrieval import ColQwenLEANNRetrievalSystem
except ImportError:
raise ImportError(
"ColQwenLEANNRetrievalSystem not available. Make sure colqwen_leann_retrieval.py is in the scripts directory."
)
logger.info("Initializing ColQwen2 LEANN retrieval system...")
logger.info(f"Search method: {search_method}")
# Use filtered image paths if available, otherwise fall back to directory scanning
if image_paths:
self.retrieval_system = ColQwenLEANNRetrievalSystem(
index_path=index_path,
model_name=model_name,
search_method=search_method,
first_stage_k=first_stage_k,
rebuild_index=rebuild_index,
custom_image_paths=image_paths, # Pass specific image paths
)
else:
self.retrieval_system = ColQwenLEANNRetrievalSystem(
index_path=index_path,
model_name=model_name,
search_method=search_method,
first_stage_k=first_stage_k,
rebuild_index=rebuild_index,
custom_folder_path=screenshot_dir,
custom_folder_recursive=recursive,
)
logger.info("ColQwen2 LEANN retrieval system ready")
def _get_example_image_paths(self) -> list[str]:
"""Get image paths for the specific examples."""
image_paths = []
for ex in self.examples:
example_id = ex.get("id", "")
if not example_id:
continue
path = os.path.join(self.screenshot_dir, f"{example_id}_fullhd.png")
if os.path.exists(path) and os.path.getsize(path) > 0:
image_paths.append(path)
return image_paths
async def retrieve(self, query: str, example: dict) -> RetrievalResult:
loop = asyncio.get_event_loop()
try:
results = await loop.run_in_executor(
None, self.retrieval_system.retrieve, query, self.top_k
)
if results:
return RetrievalResult(images=results, retrieval_type="colqwen_vector")
except Exception as e:
logger.warning(f"ColQwen2 vector retrieval failed: {e}")
return RetrievalResult(retrieval_type="colqwen_vector")
def _filter_tiles_by_aspect_ratio(
tile_paths: list[str], max_aspect_ratio: float = 100.0
) -> list[str]:
"""Filter out tiles with extreme aspect ratios.
Args:
tile_paths: List of tile image paths.
max_aspect_ratio: Maximum allowed aspect ratio (default 100, ColQwen requires < 200).
Returns:
Filtered list of tile paths.
"""
from PIL import Image
filtered = []
for tile_path in tile_paths:
try:
with Image.open(tile_path) as img:
w, h = img.size
if w > 0 and h > 0:
aspect_ratio = max(w / h, h / w)
if aspect_ratio <= max_aspect_ratio:
filtered.append(tile_path)
else:
logger.warning(
f"Skipping tile with extreme aspect ratio {aspect_ratio:.2f}: {tile_path}"
)
except Exception as e:
logger.warning(f"Failed to check tile {tile_path}: {e}")
return filtered
class TiledVectorRetriever(BaseRetriever):
"""Retrieve similar image tiles using vector similarity search.
Splits dataset screenshots into fixed-size tiles, embeds each tile,
and retrieves the most relevant tiles for a query.
"""
def __init__(
self,
api_key: str,
screenshot_dir: str = "screenshots",
tiles_dir: str = "tiles",
tile_size: int = 512,
overlap: int = 0,
cache_path: str | None = None,
use_multivector: bool = True,
top_k: int = 3,
examples: list[dict] | None = None,
):
self.top_k = top_k
self.screenshot_dir = screenshot_dir
self.tiles_dir = tiles_dir
self.tile_size = tile_size
self.overlap = overlap
self.examples = examples or []
os.makedirs(screenshot_dir, exist_ok=True)
os.makedirs(tiles_dir, exist_ok=True)
# Build example_id -> URL mapping (prioritize Wikipedia URLs)
from .simpleqa_data import extract_url_from_metadata
self.id_to_url = {}
for ex in self.examples:
ex_id = ex.get("id", "")
url = extract_url_from_metadata(ex) # Uses Wikipedia-first priority
if url:
self.id_to_url[ex_id] = url
# Prepare screenshots and get tile paths
tile_paths = self._prepare_screenshots_and_tiles()
# Import retrieval system
try:
from scripts.jina_retrieval import JinaAPIRetrievalSystem
except ImportError:
try:
from jina_retrieval import JinaAPIRetrievalSystem
except ImportError:
raise ImportError("JinaAPIRetrievalSystem not available")
vector_type = "single vector" if not use_multivector else "multivector"
logger.info(f"Initializing TiledVectorRetriever with {vector_type} mode")
self.retrieval_system = JinaAPIRetrievalSystem(
api_key=api_key,
use_multivector=use_multivector,
device="cpu", # Use CPU to avoid OOM when VLM is on GPU
)
# Only embed tiles for current dataset
self.retrieval_system.embed_images(file_paths=tile_paths, cache_path=cache_path)
logger.info(
f"TiledVectorRetriever ready with {len(self.retrieval_system.image_paths)} tiles"
)
def _prepare_screenshots_and_tiles(self) -> list[str]:
"""Prepare screenshots and tiles for dataset, return tile paths."""
from .simpleqa_data import capture_screenshot_for_example, split_image_to_tiles
from tqdm import tqdm
screenshot_paths = []
missing = []
# Collect screenshot paths and identify missing
for ex in self.examples:
screenshot_path = os.path.join(
self.screenshot_dir, f"{ex['id']}_fullhd.png"
)
screenshot_paths.append(screenshot_path)
if (
not os.path.exists(screenshot_path)
or os.path.getsize(screenshot_path) == 0
):
missing.append(ex)
# Capture missing screenshots
if missing:
logger.info(f"Preparing {len(missing)} missing screenshots...")
for ex in tqdm(missing, desc="Capturing screenshots"):
capture_screenshot_for_example(ex, self.screenshot_dir)
logger.info("Screenshots prepared.")
# Split each screenshot into tiles
all_tile_paths = []
logger.info(
f"Splitting {len(screenshot_paths)} screenshots into tiles (output: {self.tiles_dir})..."
)
for screenshot_path in tqdm(screenshot_paths, desc="Splitting tiles"):
if os.path.exists(screenshot_path) and os.path.getsize(screenshot_path) > 0:
tile_paths = split_image_to_tiles(
screenshot_path, self.tiles_dir, self.tile_size, self.overlap
)
all_tile_paths.extend(tile_paths)
# Filter out tiles with extreme aspect ratios
filtered_tile_paths = _filter_tiles_by_aspect_ratio(all_tile_paths)
logger.info(
f"Prepared {len(filtered_tile_paths)} tiles from {len(screenshot_paths)} screenshots (filtered {len(all_tile_paths) - len(filtered_tile_paths)} extreme aspect ratio tiles)"
)
return filtered_tile_paths
def _extract_urls_from_results(self, results: list) -> str:
"""Extract source URLs from tile paths in results, preserving retrieval order."""
urls = []
seen = set()
for item in results:
# item is (path, score) tuple
path = item[0] if isinstance(item, tuple) else item
# Extract example_id from tile path: {example_id}_fullhd_tile_{x}_{y}.png
filename = os.path.basename(path)
# Split by _fullhd_ or just get the first part before _tile_
if "_tile_" in filename:
example_id = filename.split("_tile_")[0]
# Remove _fullhd suffix if present
if example_id.endswith("_fullhd"):
example_id = example_id[:-7]
if example_id in self.id_to_url:
url = self.id_to_url[example_id]
if url not in seen:
seen.add(url)
urls.append(url)
return ", ".join(urls)
async def retrieve(self, query: str, example: dict) -> RetrievalResult:
del example # Not used - retrieval is from pre-built index
loop = asyncio.get_event_loop()
try:
results = await loop.run_in_executor(
None, self.retrieval_system.retrieve, query, self.top_k
)
if results:
source_url = self._extract_urls_from_results(results)
return RetrievalResult(
images=results, source_url=source_url, retrieval_type="tiled_vector"
)
except Exception as e:
logger.warning(f"Tiled vector retrieval failed: {e}")
return RetrievalResult(retrieval_type="tiled_vector")
class TiledColQwenVectorRetriever(BaseRetriever):
"""Retrieve similar image tiles using ColQwen2 LEANN multi-vector retrieval.
Splits dataset screenshots into fixed-size tiles, embeds each tile with ColQwen2,
and retrieves the most relevant tiles for a query using LEANN.
"""
def __init__(
self,
index_path: str,
screenshot_dir: str = "screenshots",
tiles_dir: str = "tiles",
tile_size: int = 512,
overlap: int = 0,
model_name: str = "colqwen2",
search_method: str = "ann",
first_stage_k: int = 500,
rebuild_index: bool = False,
top_k: int = 3,
examples: list[dict] | None = None,
):
self.top_k = top_k
self.screenshot_dir = screenshot_dir
self.tiles_dir = tiles_dir
self.tile_size = tile_size
self.overlap = overlap
self.examples = examples or []
os.makedirs(screenshot_dir, exist_ok=True)
os.makedirs(tiles_dir, exist_ok=True)
# Build example_id -> URL mapping (prioritize Wikipedia URLs)
from .simpleqa_data import extract_url_from_metadata
self.id_to_url = {}
for ex in self.examples:
ex_id = ex.get("id", "")
url = extract_url_from_metadata(ex) # Uses Wikipedia-first priority
if url:
self.id_to_url[ex_id] = url
# Prepare screenshots and get tile paths
tile_paths = self._prepare_screenshots_and_tiles()
# Import ColQwen2 retrieval system
import sys
from pathlib import Path
scripts_dir = Path(__file__).parent.parent
if str(scripts_dir) not in sys.path:
sys.path.insert(0, str(scripts_dir))
try:
from colqwen_leann_retrieval import ColQwenLEANNRetrievalSystem
except ImportError:
try:
from scripts.colqwen_leann_retrieval import ColQwenLEANNRetrievalSystem
except ImportError:
raise ImportError("ColQwenLEANNRetrievalSystem not available.")
logger.info("Initializing TiledColQwen2 LEANN retrieval system...")
logger.info(f"Search method: {search_method}, tiles: {len(tile_paths)}")
self.retrieval_system = ColQwenLEANNRetrievalSystem(
index_path=index_path,
custom_image_paths=tile_paths,
model_name=model_name,
search_method=search_method,
first_stage_k=first_stage_k,
rebuild_index=rebuild_index,
)
logger.info(
f"TiledColQwen2 LEANN retrieval system ready with {len(tile_paths)} tiles"
)
def _prepare_screenshots_and_tiles(self) -> list[str]:
"""Prepare screenshots and tiles for dataset, return tile paths."""
from .simpleqa_data import capture_screenshot_for_example, split_image_to_tiles
from tqdm import tqdm
screenshot_paths = []
missing = []
# Collect screenshot paths and identify missing
for ex in self.examples:
screenshot_path = os.path.join(
self.screenshot_dir, f"{ex['id']}_fullhd.png"
)
screenshot_paths.append(screenshot_path)
if (
not os.path.exists(screenshot_path)
or os.path.getsize(screenshot_path) == 0
):
missing.append(ex)
# Capture missing screenshots
if missing:
logger.info(f"Preparing {len(missing)} missing screenshots...")
for ex in tqdm(missing, desc="Capturing screenshots"):
capture_screenshot_for_example(ex, self.screenshot_dir)
logger.info("Screenshots prepared.")
# Split each screenshot into tiles
all_tile_paths = []
logger.info(
f"Splitting {len(screenshot_paths)} screenshots into tiles (output: {self.tiles_dir})..."
)
for screenshot_path in tqdm(screenshot_paths, desc="Splitting tiles"):
if os.path.exists(screenshot_path) and os.path.getsize(screenshot_path) > 0:
tile_paths = split_image_to_tiles(
screenshot_path, self.tiles_dir, self.tile_size, self.overlap
)
all_tile_paths.extend(tile_paths)
# Filter out tiles with extreme aspect ratios
filtered_tile_paths = _filter_tiles_by_aspect_ratio(all_tile_paths)
logger.info(
f"Prepared {len(filtered_tile_paths)} tiles from {len(screenshot_paths)} screenshots (filtered {len(all_tile_paths) - len(filtered_tile_paths)} extreme aspect ratio tiles)"
)
return filtered_tile_paths
def _extract_urls_from_results(self, results: list) -> str:
"""Extract source URLs from tile paths in results, preserving retrieval order."""
urls = []
seen = set()
for item in results:
# item is (path, score) tuple
path = item[0] if isinstance(item, tuple) else item
# Extract example_id from tile path: {example_id}_fullhd_tile_{x}_{y}.png
filename = os.path.basename(path)
if "_tile_" in filename:
example_id = filename.split("_tile_")[0]
if example_id.endswith("_fullhd"):
example_id = example_id[:-7]
if example_id in self.id_to_url:
url = self.id_to_url[example_id]
if url not in seen:
seen.add(url)
urls.append(url)
return ", ".join(urls)
async def retrieve(self, query: str, example: dict) -> RetrievalResult:
del example # Not used - retrieval is from pre-built index
loop = asyncio.get_event_loop()
try:
results = await loop.run_in_executor(
None, self.retrieval_system.retrieve, query, self.top_k
)
if results:
source_url = self._extract_urls_from_results(results)
return RetrievalResult(
images=results,
source_url=source_url,
retrieval_type="tiled_colqwen_vector",
)
except Exception as e:
logger.warning(f"TiledColQwen2 vector retrieval failed: {e}")
return RetrievalResult(retrieval_type="tiled_colqwen_vector")
class TextVectorRetriever(BaseRetriever):
"""Retrieve text using LEANN vector search.
Uses LEANN's integrated embedding + indexing system for text retrieval.
Supports various embedding models (Qwen3, nomic-embed-text, OpenAI, etc.)
"""
def __init__(
self,
text_cache: dict,
index_path: str,
embedding_model: str = "Qwen/Qwen3-Embedding-0.6B",
embedding_mode: str = "sentence-transformers",
embedding_options: dict | None = None,
top_k: int = 3,
rebuild_index: bool = False,
chunk_size: int = 512,
chunk_overlap: int = 128,
):
"""Initialize TextVectorRetriever.
Args:
text_cache: Dict of {id: {"text": ..., "url": ...}}
index_path: Path to LEANN index
embedding_model: Embedding model name (default: Qwen/Qwen3-Embedding-0.6B)
embedding_mode: Embedding mode (sentence-transformers, openai, gemini, ollama)
embedding_options: Additional options for embedding (e.g., base_url, api_key for OpenAI-compatible APIs)
top_k: Number of results to retrieve
rebuild_index: Force rebuild index even if exists
chunk_size: Max tokens per chunk (default: 512)
chunk_overlap: Overlap tokens between chunks (default: 128)
"""
import sys
from pathlib import Path as PathLib
# Add LEANN to path
leann_path = (
PathLib(__file__).parent.parent.parent
/ "LEANN"
/ "packages"
/ "leann-core"
/ "src"
)
if str(leann_path) not in sys.path:
sys.path.insert(0, str(leann_path))
from leann.api import LeannBuilder, LeannSearcher
self.text_cache = text_cache
self.top_k = top_k
self.index_path = index_path
self.embedding_model = embedding_model
self.embedding_mode = embedding_mode
self.embedding_options = embedding_options or {}
self.chunk_size = chunk_size
self.chunk_overlap = chunk_overlap
# Check if index exists
meta_path = f"{index_path}.meta.json"
index_exists = os.path.exists(meta_path)
if rebuild_index or not index_exists:
logger.info(f"Building LEANN text index at {index_path}...")
self._build_index(LeannBuilder)
logger.info(f"LEANN text index built with {len(text_cache)} documents")
else:
logger.info(f"Loading existing LEANN text index from {index_path}")
# Load searcher
self.searcher = LeannSearcher(index_path)
logger.info(
f"TextVectorRetriever ready with {len(text_cache)} documents, top_k={top_k}"
)
def _build_index(self, LeannBuilder):
"""Build LEANN index from text_cache with chunking for long texts."""
builder = LeannBuilder(
backend_name="hnsw",
embedding_model=self.embedding_model,
embedding_mode=self.embedding_mode,
embedding_options=self.embedding_options,
is_recompute=False, # Store embeddings to avoid recomputing at search time
)
# Chunking parameters (from CLI or defaults)
max_tokens = self.chunk_size
overlap_tokens = self.chunk_overlap
# Import tiktoken for accurate chunking
try:
import tiktoken
enc = tiktoken.get_encoding("cl100k_base")
except ImportError:
enc = None
logger.warning("tiktoken not available, using character-based chunking")
chunk_count = 0
for example_id, data in self.text_cache.items():
text = data.get("text", "")
url = data.get("url", "")
if not text:
continue
if enc:
# Token-based chunking
tokens = enc.encode(text)
if len(tokens) <= max_tokens:
# Short text, add as single passage
builder.add_text(text, metadata={"id": example_id, "url": url})
chunk_count += 1
else:
# Long text, chunk it with overlap
start = 0
chunk_idx = 0
while start < len(tokens):
end = min(start + max_tokens, len(tokens))
chunk_tokens = tokens[start:end]
chunk_text = enc.decode(chunk_tokens)
chunk_id = f"{example_id}_chunk_{chunk_idx}"
builder.add_text(
chunk_text,
metadata={
"id": chunk_id,
"original_id": example_id,
"url": url,
"chunk_idx": chunk_idx,
},
)
chunk_count += 1
chunk_idx += 1
if end >= len(tokens):
break
start = end - overlap_tokens # Overlap
else:
# Fallback: character-based chunking (~4 chars per token)
max_chars = max_tokens * 4
overlap_chars = overlap_tokens * 4
if len(text) <= max_chars:
builder.add_text(text, metadata={"id": example_id, "url": url})
chunk_count += 1
else:
start = 0
chunk_idx = 0
while start < len(text):
end = min(start + max_chars, len(text))
chunk_text = text[start:end]
chunk_id = f"{example_id}_chunk_{chunk_idx}"
builder.add_text(
chunk_text,
metadata={
"id": chunk_id,
"original_id": example_id,
"url": url,
"chunk_idx": chunk_idx,
},
)
chunk_count += 1
chunk_idx += 1
if end >= len(text):
break
start = end - overlap_chars
logger.info(
f"Created {chunk_count} chunks from {len(self.text_cache)} documents"
)
# Build index
builder.build_index(self.index_path)
async def retrieve(self, query: str, example: dict) -> RetrievalResult:
"""Retrieve relevant texts using LEANN vector search."""
del example # Not used - retrieval is from pre-built index
loop = asyncio.get_event_loop()
try:
# Run search in executor (LEANN search is sync)
results = await loop.run_in_executor(
None,
lambda: self.searcher.search(
query, top_k=self.top_k, recompute_embeddings=False
),
)
if results:
# Combine retrieved texts
texts = []
urls = []
for r in results:
texts.append(r.text)
url = r.metadata.get("url", "") if r.metadata else ""
urls.append(url)
combined_text = "\n\n---\n\n".join(texts)
combined_urls = ", ".join(u for u in urls if u)
return RetrievalResult(
text=combined_text,
source_url=combined_urls,
retrieval_type="text_vector",
)
except Exception as e:
logger.warning(f"Text vector retrieval failed: {e}")
return RetrievalResult(retrieval_type="text_vector")
class DsServeRetriever(BaseRetriever):
"""Use ds-serve API for external text augmentation.
Calls ds-serve search API to retrieve relevant text passages for the query.
"""
def __init__(
self, api_url: str = "http://api.ds-serve.org:30888/search", top_k: int = 3
):
self.api_url = api_url
self.top_k = top_k
async def retrieve(self, query: str, example: dict) -> RetrievalResult:
import aiohttp
import asyncio
max_retries = 3
for attempt in range(max_retries):
try:
headers = {"Content-Type": "application/json"}
payload = {"query": query}
async with aiohttp.ClientSession() as session:
async with session.post(
self.api_url,
headers=headers,
json=payload,
timeout=aiohttp.ClientTimeout(total=30),
) as response:
if response.status == 200:
result = await response.json()
# Extract passages from response
passages = []
if "results" in result and "passages" in result["results"]:
# passages is a list of lists, get the first list
passage_list = (
result["results"]["passages"][0]
if result["results"]["passages"]
else []
)
# Take top_k passages
for i, passage_data in enumerate(
passage_list[: self.top_k]
):
if isinstance(passage_data, dict):
text = passage_data.get(
"text", ""
) or passage_data.get("center_text", "")
if text:
passages.append(text)
# Combine passages into context text
if passages:
combined_text = "\n\n".join(
[
f"[Passage {i + 1}]\n{text}"
for i, text in enumerate(passages)
]
)
return RetrievalResult(
text=combined_text,
source_url=f"ds-serve:{self.api_url}",
retrieval_type="ds_serve",
)
else:
return RetrievalResult(
text="No passages found from ds-serve.",
source_url=f"ds-serve:{self.api_url}",
retrieval_type="ds_serve",
)
elif response.status == 429:
if attempt < max_retries - 1:
wait_time = min(2**attempt * 2, 10)
logger.warning(
f"Rate limited (429), waiting {wait_time}s before retry ({attempt + 1}/{max_retries})"
)
await asyncio.sleep(wait_time)
continue
else:
error_msg = f"ds-serve API rate limited after {max_retries} retries"
logger.error(error_msg)
return RetrievalResult(
text=error_msg, retrieval_type="ds_serve"
)
else:
error_text = await response.text()
error_msg = f"ds-serve API error: {response.status} - {error_text[:200]}"
logger.error(error_msg)
return RetrievalResult(
text=error_msg, retrieval_type="ds_serve"
)
except asyncio.TimeoutError:
if attempt < max_retries - 1:
wait_time = min(2**attempt, 5)
logger.warning(
f"Timeout, waiting {wait_time}s before retry ({attempt + 1}/{max_retries})"
)
await asyncio.sleep(wait_time)
continue
else:
error_msg = f"ds-serve API timeout after {max_retries} retries"
logger.error(error_msg)
return RetrievalResult(text=error_msg, retrieval_type="ds_serve")
except Exception as e:
error_msg = f"ds-serve API call failed: {e}"
logger.error(error_msg)
return RetrievalResult(text=error_msg, retrieval_type="ds_serve")
return RetrievalResult(
text="ds-serve API call failed after all retries", retrieval_type="ds_serve"
)
class LocalAPIRetriever(BaseRetriever):
"""Retrieve tiles from a local search API (e.g. localhost:30888/search).
The API accepts batch queries:
{"queries": [{"text": "..."}, ...], "n_docs": N}
and returns:
{"results": [{"hits": [{"path": ..., "url": ..., "score": ...}, ...]}, ...]}
Call prefetch(examples) before the main loop to batch all queries in one API
call. Individual retrieve() calls then return cached results instantly.
When query_rewrite is enabled, uses an LLM to rewrite questions into
keyword-rich search queries before retrieval.
"""
REWRITE_PROMPT = (
"You are a search query optimizer. Given a trivia/factual question, "
"rewrite it as a Wikipedia search query that would find the article "
"containing the answer. Output ONLY the search query, nothing else.\n\n"
"Rules:\n"
"- Focus on the key entity or topic the question is about\n"
"- Include all specific names, dates, awards, events, or other details mentioned\n"
"- Remove filler words like 'what is', 'who was', 'in which year'\n"
"- Preserve all proper nouns and technical terms exactly as written\n\n"
"Question: {question}\n"
"Search query:"
)
def __init__(
self,
api_url: str = "http://localhost:30888/search",
top_k: int = 5,
batch_size: int = 32,
query_rewrite: bool = False,
rewrite_model: str | None = None,
rewrite_api_base: str | None = None,
rewrite_api_key: str = "dummy",
nprobe: int | None = None,
reranker=None,
rerank_top_k: int = 3,
query_image_fn=None,
multi_image_query: bool = False,
tiles_dir: str = "tiles/evqa",
lookup_reference_url: bool = False,
query_instruction: str | None = None,
):
self.api_url = api_url
self.top_k = top_k
self.batch_size = batch_size
self.query_rewrite = query_rewrite
self.rewrite_model = rewrite_model
self.rewrite_api_base = rewrite_api_base
self.rewrite_api_key = rewrite_api_key
self.nprobe = nprobe
self.reranker = reranker
self.rerank_top_k = rerank_top_k
self.query_image_fn = query_image_fn # callable(example) -> image_path or None
self.multi_image_query = multi_image_query
self.tiles_dir = tiles_dir
self.lookup_reference_url = lookup_reference_url
self.query_instruction = query_instruction
self._cache: dict[str, list[dict]] = {} # example_id -> hits
self._rewritten_queries: dict[str, str] = {} # example_id -> rewritten query
async def _rewrite_queries(self, examples: list[dict]) -> dict[str, str]:
"""Batch-rewrite questions into search queries using an LLM."""
from openai import AsyncOpenAI
client = AsyncOpenAI(
api_key=self.rewrite_api_key,
base_url=self.rewrite_api_base,
timeout=60.0,
)
rewritten = {}
sem = asyncio.Semaphore(20)
async def rewrite_one(ex):
eid = ex.get("id", "unknown")
prompt = self.REWRITE_PROMPT.format(question=ex["problem"])
async with sem:
try:
resp = await client.chat.completions.create(
model=self.rewrite_model,
messages=[{"role": "user", "content": prompt}],
temperature=0.0,
max_tokens=200,
)
rewritten[eid] = resp.choices[0].message.content.strip()
except Exception as e:
logger.warning(f"Query rewrite failed for {eid}: {e}")
rewritten[eid] = ex["problem"] # fallback to original
await asyncio.gather(*[rewrite_one(ex) for ex in examples])
return rewritten
def _lookup_reference_tiles(self, examples: list[dict]) -> dict[str, list[dict]]:
"""Look up reference URL tiles from kiwix for each example.
Returns dict: example_id -> list of hit dicts with path/score/url/is_reference.
"""
import sys as _sys
from .simpleqa_data import extract_url_from_metadata
if not os.path.isdir(_KIWIX_OUTPUT_DIR) or not os.path.isfile(
_KIWIX_ARTICLES_JSON
):
logger.error(
f"lookup_reference_url: kiwix tiles unavailable at {_KIWIX_OUTPUT_DIR}"
)
return {}
if _WIKI_SCREENSHOT_DIR not in _sys.path:
_sys.path.insert(0, _WIKI_SCREENSHOT_DIR)
from scripts.build_index import batch_query_by_url as _batch_query
# Collect URLs, group by URL to avoid duplicate lookups
url_to_eids: dict[str, list[str]] = {}
for ex in examples:
eid = ex.get("id", "unknown")
url = extract_url_from_metadata(ex)
if url and "wikipedia.org" in url:
url_to_eids.setdefault(url, []).append(eid)
if not url_to_eids:
return {}
redirects = (
_KIWIX_REDIRECTS_JSON if os.path.isfile(_KIWIX_REDIRECTS_JSON) else None
)
results = _batch_query(
_KIWIX_OUTPUT_DIR,
list(url_to_eids.keys()),
_KIWIX_ARTICLES_JSON,
redirects_json=redirects,
)
ref_tiles: dict[str, list[dict]] = {}
found, missing = 0, 0
for url, eids in url_to_eids.items():
result = results.get(url)
if result is None:
missing += 1
logger.warning(f"lookup_reference_url: URL not found in kiwix: {url}")
continue
tiles_dir_abs = os.path.join(_KIWIX_OUTPUT_DIR, result["tiles_dir"])
if not os.path.isdir(tiles_dir_abs):
missing += 1
logger.warning(
f"lookup_reference_url: tiles dir missing: {tiles_dir_abs}"
)
continue
chunks = sorted(
f
for f in os.listdir(tiles_dir_abs)
if f.startswith("chunk_") and f.endswith(".png")
)
if not chunks:
missing += 1
logger.warning(
f"lookup_reference_url: no chunk files in {tiles_dir_abs}"
)
continue
found += 1
hits = [
{
"path": os.path.join(tiles_dir_abs, c),
"score": 0.0,
"url": url,
"is_reference": True,
}
for c in chunks
]
for eid in eids:
ref_tiles[eid] = hits
logger.info(
f"lookup_reference_url: batch lookup {found} found, {missing} missing "
f"out of {len(url_to_eids)} unique URLs"
)
return ref_tiles
async def prefetch(self, examples: list[dict]):
"""Batch-fetch retrieval results for all examples via the API."""
import aiohttp
# Step 1: Query rewriting (if enabled)
if self.query_rewrite and self.rewrite_model:
to_rewrite = [
ex
for ex in examples
if ex.get("id", "unknown") not in self._rewritten_queries
]
if to_rewrite:
logger.info(
f"LocalAPIRetriever: rewriting {len(to_rewrite)} queries..."
)
self._rewritten_queries.update(await self._rewrite_queries(to_rewrite))
# Log some examples
for ex in to_rewrite[:3]:
eid = ex.get("id", "unknown")
orig = ex["problem"][:60]
rewr = self._rewritten_queries.get(eid, "")[:60]
logger.info(f" Rewrite: '{orig}...' -> '{rewr}'")
# Step 2: Build query list
queries = []
example_ids = []
if self.multi_image_query:
# Multi-image: send one query per image, track which example each belongs to
# We'll aggregate after receiving results
multi_image_groups: dict[
str, list[int]
] = {} # eid -> list of indices in queries[]
for ex in examples:
eid = ex.get("id", "unknown")
if eid in self._cache:
continue
if self.query_rewrite and eid in self._rewritten_queries:
query_text = self._rewritten_queries[eid]
else:
query_text = ex["problem"]
all_paths = _get_all_query_image_paths(ex, self.tiles_dir)
if len(all_paths) <= 1:
# Single or no image: just use the standard path
query_dict = {"text": query_text}
if all_paths:
import base64
with open(all_paths[0], "rb") as f:
query_dict["image"] = base64.b64encode(f.read()).decode()
elif self.query_image_fn:
img_path = self.query_image_fn(ex)
if img_path and os.path.exists(img_path):
import base64
with open(img_path, "rb") as f:
query_dict["image"] = base64.b64encode(
f.read()
).decode()
multi_image_groups[eid] = [len(queries)]
queries.append(query_dict)
example_ids.append(eid)
else:
# Multiple images: one query per image
group_indices = []
import base64
for img_path in all_paths:
query_dict = {"text": query_text}
with open(img_path, "rb") as f:
query_dict["image"] = base64.b64encode(f.read()).decode()
group_indices.append(len(queries))
queries.append(query_dict)
example_ids.append(eid)
multi_image_groups[eid] = group_indices
logger.info(
f"Multi-image query for {eid[:8]}: {len(all_paths)} images"
)
else:
for ex in examples:
eid = ex.get("id", "unknown")
if eid in self._cache:
continue
if self.query_rewrite and eid in self._rewritten_queries:
query_text = self._rewritten_queries[eid]
else:
query_text = ex["problem"]
query_dict = {"text": query_text}
if self.query_image_fn:
img_path = self.query_image_fn(ex)
if img_path and os.path.exists(img_path):
import base64
with open(img_path, "rb") as f:
query_dict["image"] = base64.b64encode(f.read()).decode()
queries.append(query_dict)
example_ids.append(eid)
if not queries:
logger.info("LocalAPIRetriever: all examples already cached")
return
# Use smaller batches when queries contain images (GPU memory)
has_images = any("image" in q for q in queries)
batch_size = min(self.batch_size, 16) if has_images else self.batch_size
logger.info(
f"LocalAPIRetriever: prefetching {len(queries)} queries in batches of {batch_size}"
f"{' (multimodal)' if has_images else ''}"
)
for batch_start in range(0, len(queries), batch_size):
batch_queries = queries[batch_start : batch_start + batch_size]
batch_ids = example_ids[batch_start : batch_start + batch_size]
n_docs = self.top_k * 2 if self.multi_image_query else self.top_k
payload = {
"queries": batch_queries,
"n_docs": n_docs,
"include_images": True,
}
if self.nprobe is not None:
payload["nprobe"] = self.nprobe
if self.query_instruction is not None:
payload["instruction"] = self.query_instruction
try:
async with aiohttp.ClientSession() as session:
async with session.post(
self.api_url,
json=payload,
timeout=aiohttp.ClientTimeout(total=600),
) as response:
if response.status != 200:
error_text = await response.text()
logger.error(
f"Local API batch error {response.status}: {error_text[:200]}"
)
for eid in batch_ids:
self._cache[eid] = []
continue
result = await response.json()
except Exception as e:
logger.error(f"Local API batch call failed: {e}")
for eid in batch_ids:
self._cache[eid] = []
continue
results_list = result.get("results", [])
for i, eid in enumerate(batch_ids):
if i < len(results_list):
hits = results_list[i].get("hits", [])
else:
hits = []
if eid not in self._cache:
self._cache[eid] = hits
else:
# Multi-image: accumulate hits from all images for this example
self._cache[eid].extend(hits)
logger.info(
f" Batch {batch_start // batch_size + 1}/{(len(queries) + batch_size - 1) // batch_size}: "
f"{len(batch_queries)} queries done"
)
# Multi-image aggregation: deduplicate and keep max score per tile path
if self.multi_image_query:
for eid in list(self._cache.keys()):
hits = self._cache[eid]
if not hits:
continue
# Aggregate by path: keep hit with max score
best_by_path: dict[str, dict] = {}
for hit in hits:
path = hit.get("path", "")
score = hit.get("score", 0.0)
if path not in best_by_path or score > best_by_path[path].get(
"score", 0.0
):
best_by_path[path] = hit
# Sort by score descending, take top_k
sorted_hits = sorted(
best_by_path.values(),
key=lambda h: h.get("score", 0.0),
reverse=True,
)
self._cache[eid] = sorted_hits[: self.top_k]
logger.info(f"LocalAPIRetriever: prefetch complete, {len(self._cache)} cached")
# Step 2.5: Merge reference URL tiles (if enabled) — chunk-level dedup
if self.lookup_reference_url:
ref_tiles = self._lookup_reference_tiles(examples)
total_added, total_skipped = 0, 0
for eid, ref_hits in ref_tiles.items():
existing = self._cache.get(eid, [])
existing_paths = {hit.get("path", "") for hit in existing}
new_chunks = [rh for rh in ref_hits if rh["path"] not in existing_paths]
skipped = len(ref_hits) - len(new_chunks)
if new_chunks:
logger.info(
f" [{eid[:8]}]: adding {len(new_chunks)} reference URL chunks "
f"({skipped} already in API results)"
)
self._cache[eid] = existing + new_chunks
total_added += len(new_chunks)
total_skipped += skipped
logger.info(
f"lookup_reference_url: added {total_added} chunks, "
f"skipped {total_skipped} duplicates"
)
# Step 3: Rerank (if reranker provided)
if self.reranker is not None:
# Build batch of (query, candidates) for all examples
batch_inputs = []
batch_eids = []
for ex in examples:
eid = ex.get("id", "unknown")
hits = self._cache.get(eid, [])
if not hits:
continue
candidates = []
for hit in hits:
path = hit.get("path", "")
score = hit.get("score", 0.0)
if path and os.path.exists(path):
candidates.append((path, score))
if not candidates:
continue
batch_inputs.append((ex["problem"], candidates))
batch_eids.append(eid)
if batch_inputs:
all_reranked = self.reranker.rerank_batch(
batch_inputs,
top_k=self.rerank_top_k,
)
# Update cache with reranked results
for eid, reranked_results in zip(batch_eids, all_reranked):
hits = self._cache[eid]
path_to_hit = {hit["path"]: hit for hit in hits if "path" in hit}
new_hits = []
for path, rerank_score in reranked_results:
orig_hit = path_to_hit.get(path, {})
new_hits.append(
{**orig_hit, "path": path, "score": rerank_score}
)
self._cache[eid] = new_hits
logger.info(
f"LocalAPIRetriever: reranking complete ({len(batch_inputs)} examples)"
)
@staticmethod
@staticmethod
def _resolve_tile_path(hit: dict, tiles_dir: str | None = None) -> str | None:
"""Resolve tile path from hit, searching local shard dirs if needed."""
path = hit.get("path", "")
if path and os.path.exists(path):
return path
if not tiles_dir:
return path if path else None
article_id = hit.get("article_id")
tile_index = hit.get("tile_index", 0)
chunk_index = hit.get("chunk_index", 0)
if article_id is None:
return path if path else None
tiles_dirname = f"{article_id}.png.tiles"
chunk_name = f"chunk_{tile_index:04d}_{chunk_index:02d}.png"
shard_size = 8284
top_shard = article_id // shard_size
top_shard_dir = os.path.join(tiles_dir, f"shard_{top_shard:03d}")
if os.path.isdir(top_shard_dir):
for sub in sorted(os.listdir(top_shard_dir)):
sub_path = os.path.join(top_shard_dir, sub, tiles_dirname)
if os.path.isdir(sub_path):
full = os.path.join(sub_path, chunk_name)
if os.path.exists(full):
return full
flat = os.path.join(tiles_dir, tiles_dirname, chunk_name)
if os.path.exists(flat):
return flat
return path if path else None
@staticmethod
def _hits_to_result(
hits: list[dict], tiles_dir: str | None = None
) -> RetrievalResult:
"""Convert API hits to RetrievalResult."""
if not hits:
return RetrievalResult(retrieval_type="local_api")
images = []
image_urls = []
urls = []
seen_urls = set()
for hit in hits:
score = hit.get("score", 0.0)
url = hit.get("url", "")
path = LocalAPIRetriever._resolve_tile_path(hit, tiles_dir)
if path and os.path.exists(path):
images.append((path, score))
image_urls.append(url or None)
elif hit.get("image_base64"):
images.append((hit["image_base64"], 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",
)
async def retrieve(self, query: str, example: dict) -> RetrievalResult:
eid = example.get("id", "unknown")
# Return cached result if available (from prefetch)
if eid in self._cache:
return self._hits_to_result(self._cache[eid], tiles_dir=self.tiles_dir)
# Fallback: single query (if prefetch wasn't called)
import aiohttp
query_dict = {"text": query}
if self.query_image_fn:
img_path = self.query_image_fn(example)
if img_path and os.path.exists(img_path):
import base64
with open(img_path, "rb") as f:
query_dict["image"] = base64.b64encode(f.read()).decode()
payload = {"queries": [query_dict], "n_docs": self.top_k}
if self.nprobe is not None:
payload["nprobe"] = self.nprobe
if self.query_instruction is not None:
payload["instruction"] = self.query_instruction
try:
async with aiohttp.ClientSession() as session:
async with session.post(
self.api_url,
json=payload,
timeout=aiohttp.ClientTimeout(total=300),
) as response:
if response.status != 200:
return RetrievalResult(retrieval_type="local_api")
result = await response.json()
except Exception as e:
logger.error(f"Local API call failed: {e}")
return RetrievalResult(retrieval_type="local_api")
hits = result.get("results", [{}])[0].get("hits", [])
self._cache[eid] = hits
return self._hits_to_result(hits, tiles_dir=self.tiles_dir)
async def get_hits(self, query: str, example: dict) -> list[dict]:
"""Return raw per-hit dicts (path/url/score/...) for this example.
Used by wrappers that need per-hit granularity (e.g. HybridRetriever).
Uses the same cache as retrieve().
"""
await self.retrieve(query, example)
return self._cache.get(example.get("id", "unknown"), [])
class TiledQwen3VLEmbeddingRetriever(BaseRetriever):
"""Retrieves context by searching through image tiles using Qwen3-VL-Embedding.
Uses single vector embeddings (2048 dim) with cosine similarity for retrieval.
When *pixel_query_map* is provided the retriever embeds the rendered query
image (pixel query) instead of the raw text, so retrieval happens entirely
in pixel space.
"""
def __init__(
self,
screenshot_dir: str = "screenshots",
tiles_dir: str = "tiles",
tile_size: int | tuple[int, int] = 512,
overlap: int = 0,
cache_path: str | None = None,
model_name: str = "Qwen/Qwen3-VL-Embedding-2B",
top_k: int = 3,
examples: list[dict] | None = None,
gpu_ids: list[int] | None = None,
tensor_parallel_size: int = 1,
pixel_query_map: dict[str, str] | None = None,
multimodal_query_text_only: bool = False,
multimodal_query_image_only: bool = False,
local_wiki: bool = False,
local_wiki_screenshot_dir: str | None = None,
multi_image_query: bool = False,
prebuilt_tiles_dir: str | None = None,
embedding_backend: str = "vllm", # "vllm", "hf", or "biqwen3"
peft_adapter: str | None = None,
):
self.top_k = top_k
self.screenshot_dir = screenshot_dir
self.tiles_dir = tiles_dir
self.tile_size = tile_size
self.overlap = overlap
self.examples = examples or []
self.pixel_query_map = pixel_query_map # example_id -> pixel query image path
self.multimodal_query_text_only = multimodal_query_text_only
self.multimodal_query_image_only = multimodal_query_image_only
self.local_wiki = local_wiki
self.local_wiki_screenshot_dir = local_wiki_screenshot_dir
self.multi_image_query = multi_image_query
self.prebuilt_tiles_dir = prebuilt_tiles_dir
self.embedding_backend = embedding_backend
self.peft_adapter = peft_adapter
os.makedirs(screenshot_dir, exist_ok=True)
os.makedirs(tiles_dir, exist_ok=True)
# Build example_id -> URL mapping and deduplicate by URL
from .simpleqa_data import extract_url_from_metadata
self.id_to_url = {}
seen_urls: dict[str, str] = {} # url -> first example_id that uses it
self.url_to_representative_id: dict[
str, str
] = {} # url -> representative example_id
dedup_examples = []
for ex in self.examples:
ex_id = ex.get("id", "")
url = extract_url_from_metadata(ex)
if url:
self.id_to_url[ex_id] = url
if url not in seen_urls:
seen_urls[url] = ex_id
self.url_to_representative_id[url] = ex_id
dedup_examples.append(ex)
logger.info(
f"Deduplicated {len(self.examples)} examples -> {len(dedup_examples)} unique URLs "
f"(removed {len(self.examples) - len(dedup_examples)} duplicate pages)"
)
self._dedup_examples = dedup_examples
# Prepare tile paths: prebuilt dir (hard mini-datastore), local-wiki, or Selenium
if self.prebuilt_tiles_dir:
tile_paths = self._load_prebuilt_tiles()
elif self.local_wiki:
tile_paths = self._prepare_local_wiki_tiles()
else:
tile_paths = self._prepare_screenshots_and_tiles()
# Import Qwen3-VL-Embedding retrieval system
import sys
from pathlib import Path
scripts_dir = Path(__file__).parent.parent
if str(scripts_dir) not in sys.path:
sys.path.insert(0, str(scripts_dir))
try:
from qwen3vl_embedding_retrieval import Qwen3VLEmbeddingSystem
except ImportError:
try:
from scripts.qwen3vl_embedding_retrieval import Qwen3VLEmbeddingSystem
except ImportError:
raise ImportError("Qwen3VLEmbeddingSystem not available.")
logger.info("Initializing Qwen3-VL-Embedding retrieval system...")
logger.info(f"Model: {model_name}, tiles: {len(tile_paths)}, GPUs: {gpu_ids}")
if self.pixel_query_map:
logger.info(
f"Pixel query mode ENABLED ({len(self.pixel_query_map)} queries)"
)
self.retrieval_system = Qwen3VLEmbeddingSystem(
model_name=model_name,
cache_path=cache_path,
gpu_ids=gpu_ids,
tensor_parallel_size=tensor_parallel_size,
backend=self.embedding_backend,
peft_adapter=self.peft_adapter,
)
# Embed all tiles (batch_size=8 for HF backend to avoid OOM on shared GPUs)
embed_bs = 8 if self.embedding_backend == "hf" else 32
self.retrieval_system.embed_images(
file_paths=tile_paths,
cache_path=cache_path,
batch_size=embed_bs,
)
logger.info(
f"Qwen3-VL-Embedding retrieval ready with {len(self.retrieval_system.image_paths)} tiles"
)
def _load_prebuilt_tiles(self) -> list[str]:
"""Load ALL .png tiles from a prebuilt tile directory (e.g. hard mini-datastore).
Unlike _prepare_local_wiki_tiles which only loads golden tiles matching
example IDs, this loads every tile in the directory — including distractors.
"""
import glob as _glob
all_tiles = sorted(_glob.glob(os.path.join(self.prebuilt_tiles_dir, "*.png")))
filtered = _filter_tiles_by_aspect_ratio(all_tiles)
logger.info(
f"prebuilt-tiles: loaded {len(filtered)} tiles from {self.prebuilt_tiles_dir} "
f"(filtered {len(all_tiles) - len(filtered)} extreme aspect ratio tiles)"
)
return filtered
def _prepare_local_wiki_tiles(self) -> list[str]:
"""Prepare tiles from local kiwix tile store for all examples in the batch.
Does a single batch URL lookup (fast), then copies+cuts tiles per example.
Reports an error (no fallback) if a URL is not found in kiwix.
Returns the list of all cut tile paths ready for embedding.
"""
import glob as _glob
import shutil
import sys as _sys
from PIL import Image
from .simpleqa_data import extract_url_from_metadata
from tqdm import tqdm
cut_height = (
self.tile_size[1] if isinstance(self.tile_size, tuple) else self.tile_size
)
wiki_cache = self.local_wiki_screenshot_dir or os.path.join(
self.screenshot_dir, "local-wiki"
)
os.makedirs(wiki_cache, exist_ok=True)
os.makedirs(self.tiles_dir, exist_ok=True)
# Separate already-cached examples from ones that need processing
need: list[tuple[str, str]] = [] # (ex_id, url)
for ex in self._dedup_examples:
ex_id = ex["id"]
if not _glob.glob(os.path.join(self.tiles_dir, f"{ex_id}_tile_*.png")):
url = extract_url_from_metadata(ex) or ""
need.append((ex_id, url))
logger.info(
f"local-wiki: {len(self._dedup_examples) - len(need)} cached, {len(need)} need processing"
)
if need:
# Single batch lookup for all URLs at once (loads articles.json once)
if not os.path.isdir(_KIWIX_OUTPUT_DIR) or not os.path.isfile(
_KIWIX_ARTICLES_JSON
):
logger.error(
f"local-wiki: kiwix tiles unavailable at {_KIWIX_OUTPUT_DIR}"
)
else:
if _WIKI_SCREENSHOT_DIR not in _sys.path:
_sys.path.insert(0, _WIKI_SCREENSHOT_DIR)
from scripts.build_index import batch_query_by_url as _batch_query
redirects = (
_KIWIX_REDIRECTS_JSON
if os.path.isfile(_KIWIX_REDIRECTS_JSON)
else None
)
urls_to_lookup = [u for _, u in need if u and "wikipedia.org" in u]
results = _batch_query(
_KIWIX_OUTPUT_DIR,
urls_to_lookup,
_KIWIX_ARTICLES_JSON,
redirects_json=redirects,
)
found = sum(1 for r in results.values() if r is not None)
logger.info(
f"local-wiki: batch lookup found {found}/{len(urls_to_lookup)} URLs"
)
# Copy + cut per example
ok, failed = 0, 0
for ex_id, url in tqdm(need, desc="local-wiki: copying+cutting tiles"):
# Check cache again (may have been done by a parallel run)
if _glob.glob(os.path.join(self.tiles_dir, f"{ex_id}_tile_*.png")):
ok += 1
continue
result = results.get(url)
if result is None:
logger.error(
f"local-wiki [{ex_id}]: URL not found in kiwix: {url}"
)
failed += 1
continue
src_dir = os.path.join(_KIWIX_OUTPUT_DIR, result["tiles_dir"])
article_cache = os.path.join(wiki_cache, str(ex_id))
if not os.path.exists(article_cache):
if not os.path.isdir(src_dir):
logger.error(
f"local-wiki [{ex_id}]: tiles dir not on disk: {src_dir}"
)
failed += 1
continue
shutil.copytree(src_dir, article_cache)
# Cut into strips
raw_tiles = sorted(
f
for f in os.listdir(article_cache)
if f.endswith(".png") and f.startswith("tile_")
)
if not raw_tiles:
logger.error(
f"local-wiki [{ex_id}]: no tile PNGs in {article_cache}"
)
failed += 1
continue
global_row = 0
for raw_name in raw_tiles:
raw_path = os.path.join(article_cache, raw_name)
if os.path.getsize(raw_path) == 0:
continue
try:
img = Image.open(raw_path)
img.load()
except Exception as e:
logger.warning(
f"local-wiki [{ex_id}]: corrupt tile {raw_path}: {e}"
)
continue
w, h = img.size
y = 0
while y < h:
y2 = min(y + cut_height, h)
img.crop((0, y, w, y2)).save(
os.path.join(
self.tiles_dir, f"{ex_id}_tile_{global_row}_0.png"
)
)
global_row += 1
y += cut_height
img.close()
ok += 1
logger.info(
f"local-wiki: {ok} articles prepared, {failed} not found/failed"
)
all_tile_paths = []
for ex in self._dedup_examples:
ex_id = ex["id"]
tiles = sorted(
_glob.glob(os.path.join(self.tiles_dir, f"{ex_id}_tile_*.png"))
)
all_tile_paths.extend(tiles)
filtered = _filter_tiles_by_aspect_ratio(all_tile_paths)
logger.info(
f"local-wiki: {len(filtered)} tiles ready for embedding "
f"(filtered {len(all_tile_paths) - len(filtered)} extreme aspect ratio tiles)"
)
return filtered
def _prepare_screenshots_and_tiles(self) -> list[str]:
"""Prepare screenshots and tiles for dataset, return tile paths.
Uses deduplicated examples (one per unique URL) to avoid
duplicate tiles inflating the retrieval index.
"""
from .simpleqa_data import capture_screenshot_for_example, split_image_to_tiles
from tqdm import tqdm
examples_to_process = self._dedup_examples
screenshot_paths = []
missing = []
# Collect screenshot paths and identify missing (deduplicated)
for ex in examples_to_process:
screenshot_path = os.path.join(
self.screenshot_dir, f"{ex['id']}_fullhd.png"
)
screenshot_paths.append(screenshot_path)
if (
not os.path.exists(screenshot_path)
or os.path.getsize(screenshot_path) == 0
):
missing.append(ex)
# Capture missing screenshots
if missing:
logger.info(f"Preparing {len(missing)} missing screenshots...")
for ex in tqdm(missing, desc="Capturing screenshots"):
capture_screenshot_for_example(ex, self.screenshot_dir)
logger.info("Screenshots prepared.")
# Split each screenshot into tiles
all_tile_paths = []
logger.info(
f"Splitting {len(screenshot_paths)} unique screenshots into tiles (output: {self.tiles_dir})..."
)
for screenshot_path in tqdm(screenshot_paths, desc="Splitting tiles"):
if os.path.exists(screenshot_path) and os.path.getsize(screenshot_path) > 0:
tile_paths = split_image_to_tiles(
screenshot_path, self.tiles_dir, self.tile_size, self.overlap
)
all_tile_paths.extend(tile_paths)
# Filter out tiles with extreme aspect ratios
filtered_tile_paths = _filter_tiles_by_aspect_ratio(all_tile_paths)
logger.info(
f"Prepared {len(filtered_tile_paths)} tiles from {len(screenshot_paths)} unique screenshots "
f"(filtered {len(all_tile_paths) - len(filtered_tile_paths)} extreme aspect ratio tiles)"
)
return filtered_tile_paths
def _extract_urls_from_results(self, results: list) -> str:
"""Extract source URLs from tile paths in results, preserving retrieval order."""
urls = []
seen = set()
for item in results:
# item is (path, score) tuple
path = item[0] if isinstance(item, tuple) else item
# Extract example_id from tile path: {example_id}_fullhd_tile_{x}_{y}.png
filename = os.path.basename(path)
if "_tile_" in filename:
example_id = filename.split("_tile_")[0]
if example_id.endswith("_fullhd"):
example_id = example_id[:-7]
if example_id in self.id_to_url:
url = self.id_to_url[example_id]
if url not in seen:
seen.add(url)
urls.append(url)
return ", ".join(urls)
# Class-level cache for iNat 2021 image_id -> file_name mapping
_inat2021_id_map: dict[int, str] | None = None
INAT2021_DATA_DIR = _INAT2021_DATA_DIR
@classmethod
def _load_inat2021_mapping(cls) -> dict[int, str]:
"""Load iNaturalist 2021 competition image_id -> file_name mapping.
Downloads val.json from the competition S3 bucket if not cached locally.
"""
if cls._inat2021_id_map is not None:
return cls._inat2021_id_map
import json
import tarfile
import urllib.request
from pathlib import Path
data_dir = Path(cls.INAT2021_DATA_DIR)
data_dir.mkdir(parents=True, exist_ok=True)
val_json = data_dir / "val.json"
if not val_json.exists():
tar_path = data_dir / "val.json.tar.gz"
if not tar_path.exists():
logger.info("Downloading iNaturalist 2021 val annotations...")
urllib.request.urlretrieve(
"https://ml-inat-competition-datasets.s3.amazonaws.com/2021/val.json.tar.gz",
str(tar_path),
)
with tarfile.open(str(tar_path), "r:gz") as tf:
tf.extractall(path=str(data_dir))
logger.info(f"Extracted iNat 2021 val.json to {val_json}")
with open(val_json) as f:
data = json.load(f)
cls._inat2021_id_map = {img["id"]: img["file_name"] for img in data["images"]}
logger.info(f"Loaded iNat 2021 mapping: {len(cls._inat2021_id_map)} images")
return cls._inat2021_id_map
def _get_inat_image_path(self, example: dict) -> str | None:
"""Get EVQA query image (iNaturalist or Landmarks). Delegates to _get_query_image_path_for_example."""
return _get_query_image_path_for_example(example, self.tiles_dir)
async def retrieve(self, query: str, example: dict) -> RetrievalResult:
# Dispatch to multi-image retrieval if enabled
if self.multi_image_query:
return await self.retrieve_multi_image(query, example)
return await self._retrieve_single(query, example)
async def _retrieve_single(self, query: str, example: dict) -> RetrievalResult:
example_id = example.get("id", "")
loop = asyncio.get_event_loop()
# Priority: pixel_query_map > iNaturalist image > text-only
pixel_query_path = None
if self.pixel_query_map and example_id in self.pixel_query_map:
pixel_query_path = self.pixel_query_map[example_id]
# Check for iNaturalist query image (multimodal text+image query)
inat_image_path = self._get_inat_image_path(example)
try:
# Determine query modality
query_image = None
if pixel_query_path and os.path.exists(pixel_query_path):
# Pixel query: image-only (rendered text as image)
query_image = pixel_query_path
query_text = None
retrieval_type = "tiled_qwen3vl_embedding_pixel_query"
elif self.multimodal_query_text_only:
# Ablation: text-only (no image)
query_image = None
query_text = query
retrieval_type = "tiled_qwen3vl_embedding_multimodal_textonly"
elif self.multimodal_query_image_only and inat_image_path:
# Ablation: image-only (no text)
query_image = inat_image_path
query_text = None
retrieval_type = "tiled_qwen3vl_embedding_multimodal_imageonly"
elif inat_image_path:
# Multimodal: text + image
query_image = inat_image_path
query_text = query
retrieval_type = "tiled_qwen3vl_embedding_multimodal"
else:
# Text-only (no query image available)
query_text = query
retrieval_type = "tiled_qwen3vl_embedding"
results = await loop.run_in_executor(
None,
lambda: self.retrieval_system.search(
text=query_text, image=query_image, top_k=self.top_k
),
)
if results:
source_url = self._extract_urls_from_results(results)
return RetrievalResult(
images=results,
source_url=source_url,
retrieval_type=retrieval_type,
pixel_query_path=pixel_query_path or inat_image_path,
query_image_path=inat_image_path,
)
else:
return RetrievalResult(
text="No relevant tiles found via Qwen3-VL-Embedding search",
retrieval_type=retrieval_type,
pixel_query_path=pixel_query_path or inat_image_path,
query_image_path=inat_image_path,
)
except Exception as e:
logger.error(f"Qwen3-VL-Embedding search failed: {e}")
return RetrievalResult(
text=f"Qwen3-VL-Embedding retrieval error: {e}",
retrieval_type="tiled_qwen3vl_embedding",
pixel_query_path=pixel_query_path or inat_image_path,
query_image_path=inat_image_path,
)
async def retrieve_multi_image(self, query: str, example: dict) -> RetrievalResult:
"""Multi-image retrieval: search with ALL query images, aggregate scores, return top-K.
For each query image, does a multimodal search (text + image), then combines
scores across all images using max-score aggregation per tile.
Falls back to single-image retrieve() if only 0-1 images available.
"""
all_image_paths = _get_all_query_image_paths(example, self.tiles_dir)
# Get single image for generation (first available, used in RetrievalResult)
single_image_path = self._get_inat_image_path(example)
if len(all_image_paths) <= 1:
return await self._retrieve_single(query, example)
example_id = example.get("id", "")
loop = asyncio.get_event_loop()
logger.info(
f"Multi-image retrieval for {example_id}: {len(all_image_paths)} query images"
)
try:
# Score aggregation: for each tile, keep the max score across all query images
tile_best_score: dict[str, float] = {}
for img_path in all_image_paths:
results = await loop.run_in_executor(
None,
lambda p=img_path: self.retrieval_system.search(
text=query, image=p, top_k=self.top_k * 2
),
)
for tile_path, score in results:
if (
tile_path not in tile_best_score
or score > tile_best_score[tile_path]
):
tile_best_score[tile_path] = score
# Sort by score descending, take top_k
sorted_tiles = sorted(
tile_best_score.items(), key=lambda x: x[1], reverse=True
)
top_results = sorted_tiles[: self.top_k]
retrieval_type = (
f"tiled_qwen3vl_embedding_multiimage_{len(all_image_paths)}imgs"
)
if top_results:
source_url = self._extract_urls_from_results(top_results)
return RetrievalResult(
images=top_results,
source_url=source_url,
retrieval_type=retrieval_type,
pixel_query_path=single_image_path,
query_image_path=single_image_path,
)
else:
return RetrievalResult(
text="No relevant tiles found via multi-image search",
retrieval_type=retrieval_type,
pixel_query_path=single_image_path,
query_image_path=single_image_path,
)
except Exception as e:
logger.error(f"Multi-image retrieval failed: {e}")
return await self._retrieve_single(query, example)
class TextAPIRetriever(BaseRetriever):
"""Retrieve text chunks from a text search API (wiki-screenshot text_search_api.py).
The API accepts:
POST /search
{"queries": [{"text": "..."}], "n_docs": N}
and returns:
{"results": [{"hits": [{"text": ..., "title": ..., "url": ..., "score": ...}, ...]}]}
Supports batch prefetch for efficient evaluation.
"""
def __init__(
self,
api_url: str = "http://localhost:30889/search",
top_k: int = 3,
batch_size: int = 32,
nprobe: int | None = None,
query_instruction: str | None = None,
reader_top_k: int | None = None,
query_image_fn=None,
):
self.api_url = api_url
self.top_k = top_k
# If reader_top_k is set and < top_k, only the first reader_top_k hits are
# passed to the reader. Mirrors the image-side reader_top_k slicing in
# run_naive_simpleqa.py so text + image cells are comparable at fixed k.
self.reader_top_k = reader_top_k
self.batch_size = batch_size
self.nprobe = nprobe
self.query_instruction = query_instruction
self.query_image_fn = query_image_fn
self._cache: dict[str, list[dict]] = {}
async def prefetch(self, examples: list[dict]):
"""Batch-fetch retrieval results for all examples."""
import aiohttp
queries = []
example_ids = []
for ex in examples:
eid = ex.get("id", "unknown")
if eid in self._cache:
continue
query_dict = {"text": ex["problem"]}
if self.query_image_fn:
img_path = self.query_image_fn(ex)
if img_path and os.path.exists(img_path):
import base64
with open(img_path, "rb") as f:
query_dict["image"] = base64.b64encode(f.read()).decode()
queries.append(query_dict)
example_ids.append(eid)
if not queries:
logger.info("TextAPIRetriever: all examples already cached")
return
has_images = any("image" in q for q in queries)
batch_size = min(self.batch_size, 16) if has_images else self.batch_size
logger.info(
f"TextAPIRetriever: prefetching {len(queries)} queries in batches of {batch_size}"
f"{' (multimodal)' if has_images else ''}"
)
for batch_start in range(0, len(queries), batch_size):
batch_queries = queries[batch_start : batch_start + batch_size]
batch_ids = example_ids[batch_start : batch_start + batch_size]
payload = {"queries": batch_queries, "n_docs": self.top_k}
if self.nprobe is not None:
payload["nprobe"] = self.nprobe
if self.query_instruction is not None:
payload["instruction"] = self.query_instruction
try:
async with aiohttp.ClientSession() as session:
async with session.post(
self.api_url,
json=payload,
timeout=aiohttp.ClientTimeout(total=600),
) as response:
if response.status != 200:
error_text = await response.text()
logger.error(
f"TextAPI batch error {response.status}: {error_text[:200]}"
)
for eid in batch_ids:
self._cache[eid] = []
continue
result = await response.json()
except Exception as e:
logger.error(f"TextAPI batch call failed: {e}")
for eid in batch_ids:
self._cache[eid] = []
continue
results_list = result.get("results", [])
for i, eid in enumerate(batch_ids):
if i < len(results_list):
self._cache[eid] = results_list[i].get("hits", [])
else:
self._cache[eid] = []
logger.info(
f" Batch {batch_start // self.batch_size + 1}/"
f"{(len(queries) + self.batch_size - 1) // self.batch_size}: "
f"{len(batch_queries)} queries done"
)
logger.info(f"TextAPIRetriever: prefetch complete, {len(self._cache)} cached")
@staticmethod
def _hits_to_result(
hits: list[dict], max_passages: int | None = None
) -> RetrievalResult:
"""Convert text API hits to RetrievalResult.
If max_passages is set, only the first max_passages hits are joined into
the reader prompt. The cache itself is not truncated, so the same cached
hits can serve multiple reader_top_k values.
"""
if not hits:
return RetrievalResult(retrieval_type="text_api")
if max_passages is not None and max_passages < len(hits):
hits = hits[:max_passages]
passages = []
urls = []
seen_urls = set()
for hit in hits:
text = hit.get("text", "")
url = hit.get("url", "")
# Option 1 (2026-04-29): no `[title]` prefix on chunks. Title is leaked
# metadata for entity-answering tasks (often contains the answer outright).
# Reader sees only the chunk content. URL lives in retrieval_result.source_url
# for logging/grading but is not injected into the prompt by build_messages.
if text:
passages.append(text)
if url and url not in seen_urls:
seen_urls.add(url)
urls.append(url)
combined_text = "\n\n".join(passages) if passages else None
return RetrievalResult(
text=combined_text,
source_url=", ".join(urls) if urls else None,
retrieval_type="text_api",
)
async def retrieve(self, query: str, example: dict) -> RetrievalResult:
eid = example.get("id", "unknown")
if eid in self._cache:
return self._hits_to_result(
self._cache[eid], max_passages=self.reader_top_k
)
# Fallback: single query
import aiohttp
payload = {"queries": [{"text": query}], "n_docs": self.top_k}
if self.nprobe is not None:
payload["nprobe"] = self.nprobe
if self.query_instruction is not None:
payload["instruction"] = self.query_instruction
try:
async with aiohttp.ClientSession() as session:
async with session.post(
self.api_url,
json=payload,
timeout=aiohttp.ClientTimeout(total=300),
) as response:
if response.status != 200:
return RetrievalResult(retrieval_type="text_api")
result = await response.json()
except Exception as e:
logger.error(f"TextAPI call failed: {e}")
return RetrievalResult(retrieval_type="text_api")
hits = result.get("results", [{}])[0].get("hits", [])
self._cache[eid] = hits
return self._hits_to_result(hits, max_passages=self.reader_top_k)
async def get_hits(self, query: str, example: dict) -> list[dict]:
"""Return raw per-hit dicts (title/text/url/score/...) for this example.
Used by wrappers that need per-chunk granularity (e.g. RenderedTextWrapper).
Uses the same cache as retrieve().
"""
await self.retrieve(query, example)
return self._cache.get(example.get("id", "unknown"), [])
class OCRWrappedRetriever(BaseRetriever):
"""Wraps an image retriever; OCRs retrieved tiles and returns text.
Ablation A pipeline: image retrieve -> OCR -> text to reader.
Talks to an OpenAI-compatible chat endpoint (PaddleOCR-VL served via vLLM).
Caches OCR output to a JSONL file keyed by absolute image path so reruns
reuse prior work.
"""
DEFAULT_PROMPT = "OCR this image. Output only the extracted text verbatim, preserving paragraph and line breaks."
def __init__(
self,
base: BaseRetriever,
ocr_url: str = "http://localhost:8202/v1",
model: str = "PaddlePaddle/PaddleOCR-VL",
api_key: str = "dummy",
cache_path: str = "ocr_cache/paddleocr_vl.jsonl",
concurrency: int = 16,
prompt: str | None = None,
timeout: float = 180.0,
max_tokens: int = 4096,
reader_top_k: int | None = None,
):
self.base = base
self.ocr_url = ocr_url.rstrip("/")
self.model = model
self.api_key = api_key
self.cache_path = cache_path
self.concurrency = concurrency
self.prompt = prompt or self.DEFAULT_PROMPT
self.timeout = timeout
self.max_tokens = max_tokens
self.reader_top_k = reader_top_k
self._cache: dict[str, str] = {}
self.tiles_dir = getattr(base, "tiles_dir", None)
self._load_cache()
def _load_cache(self):
if not os.path.isfile(self.cache_path):
return
import json
loaded = 0
try:
with open(self.cache_path) as f:
for line in f:
line = line.strip()
if not line:
continue
entry = json.loads(line)
self._cache[entry["path"]] = entry["text"]
loaded += 1
logger.info(
f"OCRWrappedRetriever: loaded {loaded} cached OCR entries from {self.cache_path}"
)
except Exception as e:
logger.warning(
f"OCRWrappedRetriever: cache load failed ({e}); starting fresh"
)
def _append_cache(self, path: str, text: str):
import json
os.makedirs(os.path.dirname(self.cache_path) or ".", exist_ok=True)
with open(self.cache_path, "a") as f:
f.write(json.dumps({"path": path, "text": text}, ensure_ascii=False) + "\n")
self._cache[path] = text
async def _ocr_one(self, path: str, session) -> str:
if path in self._cache:
return self._cache[path]
import aiohttp
import base64
try:
with open(path, "rb") as f:
img_b64 = base64.b64encode(f.read()).decode("ascii")
except Exception as e:
logger.error(f"OCR read failed for {path}: {e}")
return ""
payload = {
"model": self.model,
"messages": [
{
"role": "user",
"content": [
{
"type": "image_url",
"image_url": {"url": f"data:image/png;base64,{img_b64}"},
},
{"type": "text", "text": self.prompt},
],
}
],
"max_tokens": self.max_tokens,
"temperature": 0.0,
}
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
try:
async with session.post(
f"{self.ocr_url}/chat/completions",
json=payload,
headers=headers,
timeout=aiohttp.ClientTimeout(total=self.timeout),
) as resp:
if resp.status != 200:
err = await resp.text()
logger.error(f"OCR HTTP {resp.status} for {path}: {err[:200]}")
return ""
result = await resp.json()
text = result["choices"][0]["message"]["content"]
except Exception as e:
logger.error(f"OCR request failed for {path}: {e}")
return ""
self._append_cache(path, text)
return text
async def _batch_ocr(self, paths: list[str]) -> dict[str, str]:
import aiohttp
to_fetch = [p for p in paths if p not in self._cache]
if not to_fetch:
return {p: self._cache[p] for p in paths}
sem = asyncio.Semaphore(self.concurrency)
async with aiohttp.ClientSession() as session:
async def _one(p):
async with sem:
return await self._ocr_one(p, session)
await asyncio.gather(*[_one(p) for p in to_fetch])
return {p: self._cache.get(p, "") for p in paths}
async def prefetch(self, examples: list[dict]):
"""Forward to base's prefetch, then batch-OCR all tiles up front."""
if hasattr(self.base, "prefetch"):
await self.base.prefetch(examples)
all_paths: set[str] = set()
for ex in examples:
r = await self.base.retrieve(ex.get("problem", ""), ex)
images = (
r.images[: self.reader_top_k]
if self.reader_top_k is not None
else r.images
)
for p, _ in images:
all_paths.add(os.path.abspath(p))
uncached = [p for p in all_paths if p not in self._cache]
logger.info(
f"OCRWrappedRetriever: {len(all_paths)} unique tiles across {len(examples)} examples; "
f"{len(all_paths) - len(uncached)} cached, OCRing {len(uncached)}"
)
if uncached:
await self._batch_ocr(uncached)
async def retrieve(self, query: str, example: dict) -> RetrievalResult:
r = await self.base.retrieve(query, example)
if not r.images:
return r
images = (
r.images[: self.reader_top_k] if self.reader_top_k is not None else r.images
)
image_urls = (
r.image_urls[: self.reader_top_k]
if self.reader_top_k is not None and r.image_urls
else list(r.image_urls or [])
)
urls: list[str] = []
seen_urls: set[str] = set()
for url in image_urls:
if url and url not in seen_urls:
seen_urls.add(url)
urls.append(url)
paths = [os.path.abspath(p) for p, _ in images]
ocr_map = await self._batch_ocr(paths)
passages = [ocr_map[p].strip() for p in paths if ocr_map.get(p, "").strip()]
combined = "\n\n---\n\n".join(passages) if passages else None
return RetrievalResult(
text=combined,
images=[],
source_url=", ".join(urls) if urls else r.source_url,
retrieval_type=f"{r.retrieval_type}+ocr",
pixel_query_path=r.pixel_query_path,
query_image_path=r.query_image_path,
)
class RenderedTextWrapper(BaseRetriever):
"""Wraps a text retriever; renders each chunk as an image.
Ablation B pipeline: text retrieve -> render as Wikipedia-style image -> VLM reader.
Requires the base retriever to expose get_hits(query, example) returning
per-hit dicts with keys: title, text, url, score, article_id, chunk_index.
(TextAPIRetriever satisfies this.)
Renders are cached on disk at {render_dir}/{article_id}_{chunk_index}.png
so repeated eval runs don't re-render.
"""
def __init__(
self,
base: BaseRetriever,
render_dir: str = "rendered_chunks",
reader_top_k: int | None = None,
):
if not hasattr(base, "get_hits"):
raise TypeError(
f"RenderedTextWrapper requires base retriever with get_hits(); "
f"got {type(base).__name__}"
)
self.base = base
self.render_dir = render_dir
self.reader_top_k = reader_top_k
os.makedirs(self.render_dir, exist_ok=True)
self.tiles_dir = render_dir
async def prefetch(self, examples: list[dict]):
if hasattr(self.base, "prefetch"):
await self.base.prefetch(examples)
def _render(self, hit: dict) -> str:
from .text_renderer import render_text_chunk
article_id = hit.get("article_id", "unknown")
chunk_index = hit.get("chunk_index", 0)
out_path = os.path.join(self.render_dir, f"{article_id}_{chunk_index}.png")
if os.path.isfile(out_path):
return out_path
# No-title policy: mirrors `_hits_to_result` (line ~3035) — title/url are
# leaked metadata for entity-answering tasks and were stripped from the
# text→text path on 2026-04-29. Apply the same constraint here so
# rendered and text→text differ only in modality, not in content.
render_text_chunk(
text=hit.get("text", ""),
title=None,
url=None,
output_path=out_path,
)
return out_path
async def retrieve(self, query: str, example: dict) -> RetrievalResult:
hits = await self.base.get_hits(query, example)
if not hits:
return RetrievalResult(retrieval_type="text_api+rendered")
if self.reader_top_k is not None:
hits = hits[: self.reader_top_k]
images: list[tuple[str, float]] = []
urls: list[str] = []
seen_urls: set[str] = set()
for hit in hits:
if not hit.get("text"):
continue
path = self._render(hit)
images.append((path, float(hit.get("score", 0.0))))
url = hit.get("url", "")
if url and url not in seen_urls:
seen_urls.add(url)
urls.append(url)
return RetrievalResult(
images=images,
source_url=", ".join(urls) if urls else None,
retrieval_type="text_api+rendered",
)
class HybridRetriever(BaseRetriever):
"""Merge image (LocalAPIRetriever) and text (TextAPIRetriever) hits by raw score.
Both underlying retrievers embed with Qwen3-VL-Embedding-2B against L2-normalized
FAISS IVFFlat (IP metric) indices, so their per-hit scores are cosine similarities
on the same scale and directly comparable without any normalization step.
Each base is called with its own configured top_k, then the combined candidate pool
is sorted by score desc and the top `top_k` are kept. The reader receives the
surviving image hits as image inputs and the surviving text hits as a concatenated
text block in the same prompt — VL-4B handles mixed modality natively.
"""
def __init__(
self,
image_base: "LocalAPIRetriever",
text_base: "TextAPIRetriever",
top_k: int = 3,
reader_top_k: int | None = None,
):
if not hasattr(image_base, "get_hits"):
raise TypeError(
f"HybridRetriever.image_base requires get_hits(); got {type(image_base).__name__}"
)
if not hasattr(text_base, "get_hits"):
raise TypeError(
f"HybridRetriever.text_base requires get_hits(); got {type(text_base).__name__}"
)
self.image_base = image_base
self.text_base = text_base
self.top_k = top_k
self.reader_top_k = reader_top_k
self.tiles_dir = getattr(image_base, "tiles_dir", None)
async def prefetch(self, examples: list[dict]):
if hasattr(self.image_base, "prefetch"):
await self.image_base.prefetch(examples)
if hasattr(self.text_base, "prefetch"):
await self.text_base.prefetch(examples)
async def retrieve(self, query: str, example: dict) -> RetrievalResult:
image_hits = await self.image_base.get_hits(query, example)
text_hits = await self.text_base.get_hits(query, example)
# Tag each hit with its modality, then merge and sort by score desc.
merged: list[tuple[float, str, dict]] = []
for h in image_hits:
score = float(h.get("score", 0.0))
merged.append((score, "image", h))
for h in text_hits:
score = float(h.get("score", 0.0))
merged.append((score, "text", h))
merged.sort(key=lambda x: x[0], reverse=True)
keep_k = self.reader_top_k if self.reader_top_k is not None else self.top_k
top = merged[:keep_k]
images: list[tuple[str, float]] = []
passages: list[str] = []
urls: list[str] = []
seen_urls: set[str] = set()
for score, modality, hit in top:
url = hit.get("url", "")
if modality == "image":
path = hit.get("path", "")
if path and os.path.exists(path):
images.append((path, score))
else: # text
title = hit.get("title", "")
text = hit.get("text", "")
if text:
header = f"[{title}]" if title else ""
passages.append(f"{header}\n{text}" if header else text)
if url and url not in seen_urls:
seen_urls.add(url)
urls.append(url)
return RetrievalResult(
text="\n\n".join(passages) if passages else None,
images=images,
source_url=", ".join(urls) if urls else None,
retrieval_type="hybrid",
)
class HTMLDOMLookupRetriever(BaseRetriever):
"""Text-retrieve → DOM lookup: retrieve text chunks, then find their HTML context.
Wraps TextAPIRetriever. For each retrieved text chunk:
1. Fetches original HTML from kiwix-serve using article_id
2. Locates the chunk text within the HTML DOM
3. Extracts the enclosing semantic container (section/table/div)
4. Returns structured HTML context to the reader
This gives the reader table/list structure without needing a separate HTML index.
Falls back to plain text if DOM lookup fails for a chunk.
"""
KIWIX_BASE = "http://localhost:9454/content/wikipedia_en_all_maxi_2025-08"
def __init__(
self,
text_api_url: str = "http://localhost:30889/search",
top_k: int = 3,
nprobe: int | None = None,
query_instruction: str | None = None,
reader_top_k: int | None = None,
query_image_fn=None,
kiwix_base: str | None = None,
articles_json: str = "/path/to/data",
context_mode: str = "section",
llm_verify: bool = False,
llm_verify_model: str = "gpt-4.1-mini",
):
import json as _json
self._text_retriever = TextAPIRetriever(
api_url=text_api_url,
top_k=top_k,
nprobe=nprobe,
query_instruction=query_instruction,
reader_top_k=reader_top_k,
query_image_fn=query_image_fn,
)
if kiwix_base:
self.KIWIX_BASE = kiwix_base
self.top_k = top_k
self.reader_top_k = reader_top_k
self.context_mode = context_mode
self.llm_verify = llm_verify
self.llm_verify_model = llm_verify_model
with open(articles_json) as f:
self._articles: list[str] = _json.load(f)
self._html_cache: dict[int, str] = {}
async def prefetch(self, examples: list[dict]):
await self._text_retriever.prefetch(examples)
def _fetch_html(self, article_id: int) -> str | None:
"""Fetch article HTML from kiwix-serve (with caching)."""
if article_id in self._html_cache:
return self._html_cache[article_id]
if article_id >= len(self._articles):
return None
import requests
from urllib.parse import quote
slug = self._articles[article_id]
url = f"{self.KIWIX_BASE}/{quote(slug, safe='/:@!$&()*+,;=')}"
try:
resp = requests.get(url, timeout=15)
resp.encoding = "utf-8"
if resp.status_code != 200:
return None
self._html_cache[article_id] = resp.text
return resp.text
except Exception:
return None
@staticmethod
def _normalize(text: str) -> str:
"""Normalize text for fuzzy DOM matching."""
import re
import unicodedata
text = re.sub(r"[\xa0]", " ", text)
text = re.sub(r"[-―−﹘﹣-—–]", "-", text)
text = re.sub(r" +", " ", text)
text = unicodedata.normalize("NFKD", text)
text = "".join(c for c in text if not unicodedata.combining(c))
return text.lower()
def _dom_lookup(self, html: str, chunk_text: str) -> str | None:
"""Find the contiguous DOM span covering chunk_text, return its HTML.
Strategy:
1. Extract search keys from chunk text (table cells + prose fragments)
2. For each key, find the tightest DOM element and walk up to a
direct child of mw-parser-output
3. Return ALL direct children from the first match to the last match
(inclusive), plus everything in between — this preserves the full
contiguous region the chunk spans.
"""
from lxml import html as lxml_html, etree
tree = lxml_html.fromstring(html)
keys = self._extract_search_keys(chunk_text)
if not keys:
return None
mw_output = tree.xpath('//div[contains(@class, "mw-parser-output")]')
if not mw_output:
return None
content_root = mw_output[0]
children = list(content_root)
if not children:
return None
# For each key, find the tightest match and resolve to a
# direct-child index of mw-parser-output
matched_child_indices = set()
SKIP_TAGS = frozenset(
("script", "style", "title", "meta", "link", "nav", "header", "footer")
)
for key in keys:
key_norm = self._normalize(key)
if len(key_norm) < 4:
continue
best_elem = None
best_len = float("inf")
for elem in content_root.iter():
if not isinstance(elem, lxml_html.HtmlElement):
continue
if elem.tag in SKIP_TAGS:
continue
try:
tc = elem.text_content()
except Exception:
continue
tc_norm = self._normalize(tc)
if key_norm in tc_norm and len(tc) < best_len:
best_elem = elem
best_len = len(tc)
if best_elem is None:
continue
# Walk up from best_elem to find which direct child of content_root
# contains it
current = best_elem
while current is not None:
parent = current.getparent()
if parent is None:
break
if parent == content_root:
# current is a direct child of mw-parser-output
try:
idx = children.index(current)
matched_child_indices.add(idx)
except ValueError:
pass
break
current = parent
if not matched_child_indices:
return None
# Return contiguous range from first to last matched child (inclusive)
first = min(matched_child_indices)
last = max(matched_child_indices)
span_elems = children[first : last + 1]
# Build result: serialize all elements in the span
parts = []
for el in span_elems:
# Strip style/script/navbox noise
for tag in ("style", "script"):
for junk in list(el.iter(tag)):
if junk.getparent() is not None:
junk.getparent().remove(junk)
if hasattr(el, "xpath"):
for nav in el.xpath('.//*[contains(@class, "navbox")]'):
if nav.getparent() is not None:
nav.getparent().remove(nav)
try:
parts.append(etree.tostring(el, encoding="unicode", method="html"))
except Exception:
continue
if not parts:
return None
html_str = "\n".join(parts)
# Log oversized results but still return them (caller decides)
if len(html_str) > self.MAX_CONTAINER_CHARS * 2:
logger.warning(
"DOM lookup oversized: %d chars (max %d) for chunk starting with %r",
len(html_str),
self.MAX_CONTAINER_CHARS * 2,
chunk_text[:50],
)
# Minimum useful size
if len(html_str) < 100 and len(chunk_text) > 200:
return None
return html_str
MAX_CONTAINER_CHARS = 8000
def _find_semantic_container(self, elem) -> "lxml_html.HtmlElement": # noqa: F821
"""Walk up from matched element to find a meaningful semantic container.
Hard cap: never return a container with text_content > MAX_CONTAINER_CHARS.
Stops at mw-parser-output boundary (never returns the whole article).
"""
SEMANTIC_TAGS = {
"section",
"article",
"table",
"blockquote",
"details",
"figure",
}
STOP_CLASSES = {"mw-parser-output", "mw-body-content", "mw-body"}
MIN_CONTEXT_LEN = 200
if elem.tag in SEMANTIC_TAGS:
return elem
best = elem
current = elem
for _ in range(15):
parent = current.getparent()
if parent is None:
break
# Hard stop: never go past the article content container
parent_classes = parent.get("class", "")
if any(sc in parent_classes for sc in STOP_CLASSES):
# We've reached the article root — use section gathering instead
if self.context_mode == "section":
gathered = self._gather_section_context(current)
if gathered is not None:
return gathered
break
try:
parent_len = len(parent.text_content())
except Exception:
break
# Prefer semantic tags — even if parent exceeds size cap
# Bug fix 2: tbody→table jump — don't let size cap block us from
# reaching a semantic container that's just one level up
if parent.tag in SEMANTIC_TAGS:
return parent
# Stop if parent is too large (but we already checked semantic tags above)
if parent_len > self.MAX_CONTAINER_CHARS:
# One more chance: check if grandparent is a semantic tag
grandparent = parent.getparent()
if grandparent is not None and grandparent.tag in SEMANTIC_TAGS:
return grandparent
break
# Accept block containers that are reasonably sized
if parent_len >= MIN_CONTEXT_LEN:
best = parent
current = parent
return best
def _gather_section_context(self, elem) -> "lxml_html.HtmlElement": # noqa: F821
"""Gather all sibling elements within the same h2/h3 section."""
from lxml import etree
# Walk up to find direct child of mw-parser-output
current = elem
mw_output = None
while current is not None:
parent = current.getparent()
if parent is not None:
classes = parent.get("class", "")
if "mw-parser-output" in classes:
mw_output = parent
break
current = parent
if mw_output is None:
return elem
# Find the element's position among mw-parser-output children
children = list(mw_output)
try:
idx = children.index(current)
except ValueError:
return elem
# Gather backward until we hit a heading, forward until next heading
section_elems = [current]
# Backward
for i in range(idx - 1, max(idx - 10, -1), -1):
child = children[i]
if hasattr(child, "tag") and child.tag in ("h1", "h2", "h3"):
section_elems.insert(0, child)
break
section_elems.insert(0, child)
# Forward
for i in range(idx + 1, min(idx + 10, len(children))):
child = children[i]
if hasattr(child, "tag") and child.tag in ("h1", "h2", "h3"):
break
section_elems.append(child)
# Build a container div with these elements
container = etree.Element("div")
for el in section_elems:
try:
container.append(el)
except Exception:
pass
return container
@staticmethod
def _extract_search_keys(chunk_text: str) -> list[str]:
"""Extract distinctive search keys from chunk text for DOM matching.
Detects chunk type (table-heavy vs prose-heavy) and picks the best strategy.
Returns keys ordered by distinctiveness — first key is tried first in DOM lookup.
"""
import re
lines = chunk_text.split("\n")
# Skip first line if it looks like an article title (short, no pipes, no punctuation)
# These match <h1> in DOM and cause Bug 1
if lines and len(lines[0]) < 80 and "|" not in lines[0] and "." not in lines[0]:
content_lines = lines[1:]
else:
content_lines = lines
table_lines = [l for l in content_lines if "|" in l and "---" not in l]
prose_lines = [
l
for l in content_lines
if len(l) > 30 and "|" not in l and not l.startswith("- ^")
]
is_table_heavy = (
len(table_lines) > len(content_lines) * 0.4 if content_lines else False
)
keys = []
if is_table_heavy:
# Mixed strategy: include keys from BOTH table cells and prose
# so coverage scorer can find a container spanning both parts.
cell_candidates = []
for tl in table_lines:
cells = [c.strip() for c in tl.split("|") if c.strip()]
for cell in cells:
if len(cell) < 5 or len(cell) > 80:
continue
if cell.lower() in ("yes", "no", "n/a", "none", ""):
continue
has_code = bool(re.search(r"[A-Z]\d|[a-z]\d{4,}", cell))
has_mixed = bool(re.search(r"\d.*[a-zA-Z]|[a-zA-Z].*\d", cell))
has_proper = bool(re.search(r"[A-Z][a-z]+\s+[A-Z]", cell))
if has_code or has_mixed or has_proper:
cell_candidates.insert(0, cell)
elif len(cell) > 12:
cell_candidates.append(cell)
# Table cells first (these anchor to the infobox)
for cc in cell_candidates[:3]:
if cc not in keys:
keys.append(cc)
# Then prose keys (these anchor to body paragraphs)
for line in prose_lines[:3]:
mid = len(line) // 2
candidate = line[mid - 15 : mid + 15].strip()
if len(candidate) >= 10 and re.search(r"[a-zA-Z]{4,}", candidate):
keys.append(candidate)
else:
# Prose-dominant chunk: use prose fragments as primary keys
for line in prose_lines[:4]:
mid = len(line) // 2
candidate = line[mid - 15 : mid + 15].strip()
if len(candidate) >= 10 and re.search(r"[a-zA-Z]{4,}", candidate):
keys.append(candidate)
# Add table cell values as secondary
if table_lines:
for tl in table_lines[:5]:
cells = [
c.strip()
for c in tl.split("|")
if c.strip() and len(c.strip()) > 8
]
for cell in cells[:1]:
if cell not in keys:
keys.append(cell)
# List item content
if not keys:
list_lines = [
l[2:]
for l in lines
if l.startswith("- ") and len(l) > 20 and not l.startswith("- ^")
]
for ll in list_lines[:3]:
mid = len(ll) // 2
candidate = ll[mid - 15 : mid + 15].strip()
if len(candidate) >= 10:
keys.append(candidate)
# Fallback
if not keys and len(chunk_text) > 40:
candidate = chunk_text[10:50].strip()
keys.append(candidate)
return keys
async def _llm_dom_closure(self, raw_html: str, chunk_text: str) -> str | None:
"""Use an LLM to find the minimal DOM closure containing the chunk text.
Sends the article HTML (truncated) and the chunk text to the model,
asks it to return the minimal enclosing HTML subtree.
"""
import openai
# Truncate HTML to avoid context limits — keep first 60K chars
# (most Wikipedia articles are under 100K)
html_truncated = raw_html[:60000]
prompt = f"""Given this HTML document and a text chunk extracted from it, find the minimal DOM subtree that contains ALL the text in the chunk. Return ONLY the raw HTML of that subtree, no explanation.
The text chunk (extracted by Trafilatura, so formatting differs from HTML):
---
{chunk_text[:2000]}
---
The HTML document:
---
{html_truncated}
---
Return the minimal HTML subtree containing all the information from the text chunk. Include complete table/list structures if the chunk spans table cells. Return ONLY HTML, no markdown fences."""
try:
client = openai.AsyncOpenAI()
response = await client.chat.completions.create(
model=self.llm_verify_model,
messages=[{"role": "user", "content": prompt}],
max_tokens=8000,
temperature=0,
)
result = response.choices[0].message.content.strip()
# Strip markdown fences if present
if result.startswith("```"):
lines = result.split("\n")
result = "\n".join(
lines[1:-1] if lines[-1].startswith("```") else lines[1:]
)
return result if "<" in result else None
except Exception as e:
logger.warning(f"LLM DOM closure failed: {e}")
return None
async def retrieve(self, query: str, example: dict) -> RetrievalResult:
"""Retrieve text chunks, then do DOM lookup for HTML context."""
example.get("id", "unknown")
# Get raw hits from text retriever
hits = await self._text_retriever.get_hits(query, example)
if not hits:
return RetrievalResult(retrieval_type="html_dom_lookup")
keep_k = self.reader_top_k if self.reader_top_k is not None else self.top_k
hits = hits[:keep_k]
passages = []
urls = []
seen_urls: set[str] = set()
for hit in hits:
article_id = hit.get("article_id")
chunk_text = hit.get("text", "")
url = hit.get("url", "")
html_context = None
if article_id is not None:
raw_html = self._fetch_html(int(article_id))
if raw_html:
# Heuristic DOM lookup first
html_context = self._dom_lookup(raw_html, chunk_text)
# LLM verification/fallback
if self.llm_verify and (
html_context is None or len(chunk_text) > 500
):
llm_result = await self._llm_dom_closure(raw_html, chunk_text)
if llm_result:
html_context = llm_result
if html_context:
passages.append(html_context)
else:
passages.append(chunk_text)
if url and url not in seen_urls:
seen_urls.add(url)
urls.append(url)
# Hard cap per passage. HTML is ~2 chars/token; reader has 65K tokens
# with ~2K for output + system prompt. Budget ~50K tokens for context
# = ~100K chars across all passages. Per-passage cap avoids one huge
# article starving the others.
MAX_PER_PASSAGE = 30000
passages = [p[:MAX_PER_PASSAGE] for p in passages]
MAX_TOTAL_CHARS = 90000
total = sum(len(p) for p in passages)
if total > MAX_TOTAL_CHARS:
per_passage = MAX_TOTAL_CHARS // max(len(passages), 1)
passages = [p[:per_passage] for p in passages]
logger.warning(
"Truncated %d passages from %d to %d total chars",
len(passages),
total,
sum(len(p) for p in passages),
)
combined = "\n\n---\n\n".join(passages) if passages else None
return RetrievalResult(
text=combined,
source_url=", ".join(urls) if urls else None,
retrieval_type="html_dom_lookup",
)