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