Pixel-Native RAG: A Practical Guide to Visual Document Indexing A new tutorial from StarTrail-org introduces PixelRAG, a pixel-native retrieval-augmented generation pipeline that renders web pages and PDFs as images, divides them into overlapping tiles, and generates multimodal embeddings with SigLIP, CLIP, or Qwen3-VL backends, storing vectors in a FAISS index. The system integrates OCR-based BM25 scoring with reciprocal rank fusion, aggregates tile-level evidence into document-level results, and exposes a FastAPI search service, with evaluation using Recall@k and mean reciprocal rank. In this tutorial, we build a complete pixel https://github.com/StarTrail-org/PixelRAG -native retrieval-augmented generation pipeline from scratch and examine how document retrieval works without relying on conventional HTML parsing, text extraction, or fixed chunking strategies. We render web pages and PDF documents as images, divide them into overlapping tiles, generate multimodal embeddings with SigLIP, CLIP, or an optional Qwen3-VL backend, and store the resulting vectors in a FAISS index for efficient similarity search. We also strengthen retrieval with OCR-based BM25 scoring and reciprocal rank fusion, aggregate tile-level evidence into document-level results, and expose the system through a FastAPI search service. Along the way, we evaluate retrieval quality using Recall@k and mean reciprocal rank, train a lightweight residual adapter with contrastive learning, visualize retrieved screenshots, and optionally pass the strongest evidence tiles to a vision-language model for grounded answer generation. python import os import sys import io import re import json import time import math import shutil import hashlib import asyncio import logging import argparse import threading import subprocess from pathlib import Path from dataclasses import dataclass, field, asdict from typing import List, Dict, Any, Optional, Tuple @dataclass class Config: urls: List str = field default factory=lambda: "https://en.wikipedia.org/wiki/Retrieval-augmented generation", "https://en.wikipedia.org/wiki/Vector database", "https://en.wikipedia.org/wiki/Transformer deep learning architecture ", "https://en.wikipedia.org/wiki/Photosynthesis", "https://en.wikipedia.org/wiki/Delhi", include synthetic pdf: bool = True tile width: int = 1024 tile height: int = 1024 tile overlap: int = 128 device scale: float = 1.0 max page height: int = 24000 max tiles per doc: int = 12 min tile height: int = 200 blank std threshold: float = 6.0 dedup hamming: int = 4 nav timeout ms: int = 60000 headless args: List str = field default factory=lambda: "--no-sandbox", "--disable-dev-shm-usage", "--hide-scrollbars", "--disable-gpu", "--force-color-profile=srgb", "--font-render-hinting=none", backend: str = "siglip" model id: str = "google/siglip-base-patch16-224" qwen model id: str = "Qwen/Qwen3-VL-Embedding-2B" embed batch size: int = 8 embed image size: Optional int = None index dir: str = "./pixel index" ivf threshold: int = 2000 ivf nprobe: int = 16 top k tiles: int = 20 n docs: int = 5 use ocr hybrid: bool = True rrf k: int = 60 dense weight: float = 1.0 sparse weight: float = 1.0 enable server: bool = True server port: int = 8000 enable eval: bool = True enable adapter train: bool = True enable vlm answer: bool = False vlm model id: str = "Qwen/Qwen2.5-VL-3B-Instruct" show plots: bool = True work dir: str = "./pixelrag work" seed: int = 0 CFG = Config EVAL QUERIES: List Tuple str, str = "how do plants convert sunlight into chemical energy", "Photosynthesis" , "chlorophyll light dependent reactions", "Photosynthesis" , "converting scanned images of text into machine readable characters", "Optical character" , "approximate nearest neighbour search over embeddings", "Vector database" , "self-attention multi-head architecture", "Transformer" , "grounding a language model with retrieved documents", "Retrieval-augmented" , "capital territory of india red fort", "Delhi" , logging.basicConfig level=logging.INFO, format="% asctime s | % levelname -7s | % message s", datefmt="%H:%M:%S" log = logging.getLogger "pixelrag" for noisy in "urllib3", "PIL", "matplotlib", "httpx", "asyncio", "uvicorn.error" : logging.getLogger noisy .setLevel logging.WARNING IN COLAB = "google.colab" in sys.modules def pip pkgs: str - None: """Install quietly; never explode the notebook on a single bad wheel.""" cmd = sys.executable, "-m", "pip", "install", "-q", "--disable-pip-version-check", pkgs subprocess.run cmd, check=False, stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT def have mod: str - bool: import importlib.util return importlib.util.find spec mod is not None def ensure deps cfg: Config - None: log.info "Installing dependencies first run only, ~2-4 min ..." wanted = for mod, pkg in "PIL", "pillow" , "numpy", "numpy" , "faiss", "faiss-cpu" , "fitz", "pymupdf" , "transformers", "transformers" , "fastapi", "fastapi" , "uvicorn", "uvicorn" , "requests", "requests" , "matplotlib", "matplotlib" , "tqdm", "tqdm" , "rank bm25", "rank-bm25" , "playwright", "playwright" , "sentencepiece", "sentencepiece" , : if not have mod : wanted.append pkg if cfg.use ocr hybrid and not have "pytesseract" : wanted.append "pytesseract" if wanted: pip wanted if not have "torch" : log.warning "torch not found — installing CPU wheel Colab normally ships torch ." pip "torch", "torchvision" if cfg.use ocr hybrid and shutil.which "tesseract" is None: log.info "Installing tesseract-ocr system package..." subprocess.run "apt-get -qq update && apt-get -qq install -y tesseract-ocr", shell=True, check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL if shutil.which "tesseract" is None: log.warning "tesseract unavailable - hybrid retrieval will run dense-only." cfg.use ocr hybrid = False marker = Path cfg.work dir / ".chromium ok" if not marker.exists : log.info "Downloading Playwright Chromium..." r = subprocess.run sys.executable, "-m", "playwright", "install", "--with-deps", "chromium" , capture output=True, text=True if r.returncode = 0: r = subprocess.run sys.executable, "-m", "playwright", "install", "chromium" , capture output=True, text=True if r.returncode == 0: marker.parent.mkdir parents=True, exist ok=True marker.write text "ok" else: log.warning "Chromium install failed - falling back to the text renderer.\n%s", r.stderr or "" -600: log.info "Dependencies ready." def run async coro : """ Run a coroutine from a Jupyter/Colab cell. Colab already owns a running event loop, which makes Playwright's sync API raise. Rather than monkey-patching with nest asyncio, we hand the coroutine to a private loop on a private thread — the most robust option. """ box: Dict str, Any = {} def runner : loop = asyncio.new event loop asyncio.set event loop loop try: box "value" = loop.run until complete coro except BaseException as exc: box "error" = exc finally: try: loop.run until complete loop.shutdown asyncgens finally: loop.close t = threading.Thread target= runner, daemon=True t.start t.join if "error" in box: raise box "error" return box.get "value" We define the global configuration, evaluation queries, logging behavior, and runtime settings for the PixelRAG pipeline. We install the required Python and system dependencies, including Playwright, Chromium, Tesseract, FAISS, and transformer libraries. We also create an asynchronous execution helper that allows browser-rendering coroutines to run reliably inside Google Colab and Jupyter environments. @dataclass class Tile: tile id: str doc id: str source: str kind: str page: int seq: int y0: int y1: int path: str ocr text: str = "" title: str = "" def doc id from source src: str - str: tail = src.rstrip "/" .split "/" -1 or src tail = re.sub r"\. html?|pdf|png|jpg $", "", tail, flags=re.I return re.sub r" ^A-Za-z0-9 .\- +", " ", tail :80 or hashlib.md5 src.encode .hexdigest :10 def ahash img, size: int = 8 - int: """64-bit average hash — cheap near-duplicate detection for repeated headers.""" import numpy as np g = img.convert "L" .resize size, size a = np.asarray g, dtype="float32" bits = a a.mean .flatten out = 0 for b in bits: out = out << 1 | int b return out def hamming a: int, b: int - int: return bin a ^ b .count "1" def is informative img, cfg: Config - bool: """Reject blank / solid-colour tiles before they ever reach the GPU.""" import numpy as np a = np.asarray img.convert "L" , dtype="float32" return float a.std = cfg.blank std threshold def save tile img, out dir: Path, name: str - str: out dir.mkdir parents=True, exist ok=True p = out dir / f"{name}.png" img.convert "RGB" .save p, format="PNG", optimize=True return str p def slice image to tiles img, cfg: Config, , doc id: str, source: str, kind: str, page: int, out dir: Path, start seq: int = 0, seen hashes: Optional List int = None, title: str = "" - List Tile : """Vertical sliding window with overlap. Used for PDFs and text fallback.""" from PIL import Image seen hashes = seen hashes if seen hashes is not None else W, H = img.size if W = cfg.tile width: new h = max 1, int H cfg.tile width / W img = img.resize cfg.tile width, new h W, H = img.size step = max 1, cfg.tile height - cfg.tile overlap tiles: List Tile = y, seq = 0, start seq while y < H and seq - start seq < cfg.max tiles per doc: h = min cfg.tile height, H - y if h < cfg.min tile height and seq start seq: break crop = img.crop 0, y, W, y + h if is informative crop, cfg : hsh = ahash crop if all hamming hsh, s cfg.dedup hamming for s in seen hashes : seen hashes.append hsh tid = f"{doc id} p{page} t{seq}" tiles.append Tile tile id=tid, doc id=doc id, source=source, kind=kind, page=page, seq=seq, y0=y, y1=y + h, title=title, path= save tile crop, out dir, tid , seq += 1 y += step return tiles JS AUTOSCROLL = """ async = { await new Promise resolve = { let y = 0; const timer = setInterval = { window.scrollBy 0, 800 ; y += 800; if y = document.body.scrollHeight || y 40000 { clearInterval timer ; window.scrollTo 0, 0 ; setTimeout resolve, 250 ; } }, 40 ; } ; } """ JS FLATTEN = """ = { document.querySelectorAll ' ' .forEach el = { const s = getComputedStyle el ; if s.position === 'fixed' || s.position === 'sticky' el.style.position = 'absolute'; } ; document.querySelectorAll ' role="dialog" , .cookie, cookie-banner, .cc-banner' .forEach el = el.remove ; } """ CSS CLEANUP = """ { animation: none important; transition: none important; scroll-behavior: auto important; } html { -webkit-font-smoothing: antialiased; } video, iframe src ="youtube" { visibility: hidden important; } """ UA = "Mozilla/5.0 X11; Linux x86 64 AppleWebKit/537.36 KHTML, like Gecko " "Chrome/124.0 Safari/537.36 PixelRAG-Tutorial/1.0" async def render urls async urls: List str , cfg: Config, out dir: Path - List Tile : from playwright.async api import async playwright from PIL import Image all tiles: List Tile = async with async playwright as pw: browser = await pw.chromium.launch headless=True, args=cfg.headless args ctx = await browser.new context viewport={"width": cfg.tile width, "height": cfg.tile height}, device scale factor=cfg.device scale, user agent= UA, java script enabled=True, for url in urls: doc id = doc id from source url page = await ctx.new page try: await page.goto url, wait until="domcontentloaded", timeout=cfg.nav timeout ms try: await page.wait for load state "networkidle", timeout=12000 except Exception: pass await page.evaluate JS AUTOSCROLL await page.add style tag content= CSS CLEANUP await page.evaluate JS FLATTEN title = await page.title or doc id height = await page.evaluate " = Math.max document.body.scrollHeight, " "document.documentElement.scrollHeight " height = int min height, cfg.max page height step = max 1, cfg.tile height - cfg.tile overlap seen: List int = y, seq = 0, 0 while y < height and seq < cfg.max tiles per doc: h = min cfg.tile height, height - y if h < cfg.min tile height and seq 0: break buf = await page.screenshot full page=True, type="png", clip={"x": 0, "y": y, "width": cfg.tile width, "height": h} img = Image.open io.BytesIO buf .convert "RGB" if img.size 0 = cfg.tile width: img = img.resize cfg.tile width, max 1, int img.size 1 cfg.tile width / img.size 0 if is informative img, cfg : hsh = ahash img if all hamming hsh, s cfg.dedup hamming for s in seen : seen.append hsh tid = f"{doc id} p0 t{seq}" all tiles.append Tile tile id=tid, doc id=doc id, source=url, kind="web", page=0, seq=seq, y0=y, y1=y + h, title=title, path= save tile img, out dir, tid seq += 1 y += step log.info " rendered %-34s - %2d tiles page %dpx ", doc id, seq, height except Exception as exc: log.warning " FAILED %s %s ", url, type exc . name finally: await page.close await ctx.close await browser.close return all tiles def render urls urls: List str , cfg: Config, out dir: Path - List Tile : """Screenshot every URL into tiles; degrade to the text renderer on failure.""" try: tiles = run async render urls async urls, cfg, out dir if tiles: return tiles log.warning "Browser produced no tiles — using text-render fallback." except Exception as exc: log.warning "Playwright unavailable %s: %s — using text-render fallback.", type exc . name , str exc :160 return t for u in urls for t in render url as text u, cfg, out dir def strip html html: str - str: html = re.sub r" ?is < script|style|nav|footer|header|noscript . ?