"""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