Give wordless pictures a way to be found

A screenshot with no OCR-able words used to be a memory nothing could
reach. It now has a vector, and a question can be asked with it.

- picture space: Xenova/chinese-clip-vit-base-patch16 fp16 ONNX, 361MB,
  sha256 0fad978faa89b85f…, CPU-runnable on the onnxruntime the repo
  already ships. Text 76ms, image 29ms/image at batch 8. The ONNX contract
  was measured, not assumed: it is the fused contrastive graph, so the
  unused tower is fed a placeholder row, and the tokenizer must be
  BertTokenizer — a CLIP tokenizer emits ids past the embedding table.
- visual_index.py keeps picture vectors in their own family beside the
  text ones, keyed by atom id, and refuses to answer when the map and the
  array disagree. retrieval.py stays under 500 lines.
- a search can be asked with a picture, and when the words come up thin
  the picture ranking joins the fusion — but only then, so a text query
  never pays for the encoder.
- visual tokens are counted at 32x32 patches (migration v7): 21,325 image
  atoms went from 5,331 to 34,618 tokens, and the library from 29.9M to
  84.2M. The 100M capsule fits this 50,278-file corpus and nothing much
  larger — that is what the claim actually means.
- the vision-language describer is written and gated: with no snapshot an
  atom records "deferred: no vision-language model snapshot"; a broken
  model becomes a note on the atom instead of a dead ingest.
- forget now voids the picture index and the copy this process holds, so
  a forgotten picture stops answering from RAM.

Measured, six arms, ScreenSpot-v2 (554 icon crops, 408 whole screenshots),
bench/reports/VISUAL.md:
  icon pool      picture 0.208  words 0.175  chance 0.018
  screenshot pool words 0.384   picture 0.215
  screenshot to icon 0.108  — CLIP is a whole-image model, not a grounding
  model: it cannot point at one small icon inside a screen.
  within one screen MRR 0.908 (recall@10 is 1.000 there only because
  chance is 1.000 too, so MRR is the only honest number in that row).
ScreenSpot-v2 instructions are all English, so this run does not test the
bilingual claim and the report says so.

Gates: 199 tests, ruff clean on 17 files, mypy clean on 16 (fresh cache),
the repo's own pyright 0 errors on the 12 files touched.
This commit is contained in:
ZacharyZhang-NY committed 2026-10-07 20:41:19 -04:00
1 parent 7e1fe649f6
commit 03ca35763f
20 files changed
+1702 -22

No files matched your search

+57 -6
View File
@@ -10,15 +10,19 @@ from __future__ import annotations
import base64
from collections import defaultdict
from pathlib import Path
from . import cartridge_store
from .config import Settings, get_settings
from .db import Database
from .db import Database, FtsPage
from .embeddings import EmbeddingService
from .schemas import EvidenceAtom, RetrievalHit, SearchFilters, SearchResult
from .visual_index import VisualIndex
RRF_K = 60
CANDIDATE_WINDOW = 10
# Words decide most searches; a picture vector is only worth its encoding cost when words fell short.
THIN_SPARSE_PAGE = 2
class RetrievalService:
@@ -26,6 +30,7 @@ class RetrievalService:
self.settings = settings or get_settings()
self.db = database or Database(self.settings)
self.embeddings = EmbeddingService(self.settings, self.db)
self.visual = VisualIndex(self.settings, self.db)
def search(
self,
@@ -34,6 +39,7 @@ class RetrievalService:
*,
filters: SearchFilters | None = None,
cursor: str | None = None,
image: Path | None = None,
) -> SearchResult:
"""Answer one query against the memories that are open right now.
@@ -44,17 +50,20 @@ class RetrievalService:
limit = top_k or self.settings.retrieval_top_k
if limit <= 0:
raise ValueError(f"search needs a positive limit, got {limit}")
if not query.strip() and image is None:
raise ValueError("search needs words or a picture to ask with, and got neither")
offset = decode_cursor(cursor)
criteria = (filters or SearchFilters()).model_dump(exclude_none=True)
scope = criteria.get("cartridge_ids")
if scope is None:
scope = cartridge_store.mounted_ids(self.db) or None
criteria["cartridge_ids"] = scope
page = self.db.search_atoms(
query,
limit=limit * CANDIDATE_WINDOW,
offset=offset,
**criteria,
# A picture-only question has no words for BM25, so the word ranking is skipped rather
# than asked to match an empty expression, which would answer nothing for the wrong reason.
page = (
self.db.search_atoms(query, limit=limit * CANDIDATE_WINDOW, offset=offset, **criteria)
if query.strip()
else FtsPage(rows=[], has_more=False)
)
rankings: list[list[str]] = [[atom_id for atom_id, _ in page.rows]]
scores: dict[str, dict[str, float]] = defaultdict(dict)
@@ -68,6 +77,17 @@ class RetrievalService:
rankings.append(list(dense))
for atom_id, cosine in dense.items():
scores[atom_id]["dense_score"] = cosine
visual = self._visual_search(
query,
image,
limit * CANDIDATE_WINDOW,
scope,
thin=len(rankings[0]) < limit * THIN_SPARSE_PAGE,
)
if visual:
rankings.append(list(visual))
for atom_id, cosine in visual.items():
scores[atom_id]["visual_score"] = cosine
hits = self._rank(rankings, scores, limit)
more = page.has_more or len(rankings[0]) > limit
return SearchResult(
@@ -84,6 +104,36 @@ class RetrievalService:
query_vector = self.embeddings.embed_query(query)
return dict(index.search(query_vector, limit))
def _visual_search(
self,
query: str,
image: Path | None,
limit: int,
scope: list[str] | None,
*,
thin: bool,
) -> dict[str, float]:
"""Ask the picture index when words are not enough.
A screenshot with no text on it has nothing for FTS to match, so the sparse page comes
back short; that is the signal to spend the encoding. An image query always asks for it.
"""
if image is None and not thin:
return {}
if not self.visual.available() or self.visual.load() is None:
return {}
if image is not None:
try:
ranked = self.visual.search_image(image, limit)
except (OSError, ValueError) as error:
raise ValueError(f"the query image {image} cannot be read: {error}") from error
else:
ranked = self.visual.search_text(query, limit)
if scope:
allowed = cartridge_store.atoms_of_cartridges(self.db, scope)
ranked = [(atom_id, score) for atom_id, score in ranked if atom_id in allowed]
return dict(ranked)
def _rank(self, rankings: list[list[str]], scores: dict[str, dict[str, float]], limit: int) -> list[RetrievalHit]:
"""Reciprocal rank fusion: rank position is comparable across signals, raw scores are not.
@@ -108,6 +158,7 @@ class RetrievalService:
atom=atom,
sparse_score=signal.get("sparse_score", 0.0),
dense_score=signal.get("dense_score", 0.0),
visual_score=signal.get("visual_score", 0.0),
final_score=fused[atom_id],
)
)