Skip to content

feat: visual search, find similar items, and search inside linked PDFs - #135

Merged
devnull03 merged 12 commits into
mainfrom
feat/visual-search
Sep 20, 2026
Merged

devnull03 merged 12 commits into
mainfrom
feat/visual-search

Conversation

@devnull03

@devnull03 devnull03 commented Sep 14, 2026 •

Copy link
Copy Markdown
Owner

Visual search and search inside linked PDFs. Design doc: Visual search, similar items, and linked-file text search.

What works

Search inside linked PDFs

  • The find bar has an Include text inside linked files toggle, off by default.
  • With it on, each linked PDF's text is read once in the background (preview::document_text) and cached beside the thumbnails.
  • Matching follows the same case, whole-word and regex rules as cells. The view narrows to the rows with a hit.

Visual search

  • New visual-search crate: CLIP ViT-B/32 on the CPU through candle (pure Rust), with a hand-written CLIP tokenizer.
  • Weights: a pinned download (openai/clip-vit-base-patch32@b33cedf, 605 MB), checked against its SHA-256 before it's moved into place.
  • Image toggle in the find bar: rows are ranked by how well their linked file's thumbnail matches the query. The view temporarily shows only the hits, best first.
  • Breadth slider: keeps hits scoring within a proportion of the best one, 15% by default, adjustable from 2% to 40% while you watch. Replace is disabled during a visual search.
  • Find similar items: in the row and card context menu. It ranks by that file's own vector.
  • What gets embedded: thumbnails (preview::thumbnail_pixels), so it covers everything qrate can preview. PDFs use their first page, video its poster frame, audio its cover art.

Works in every view

  • The find bar is drawn by ViewsPanel above Table or Gallery. A search survives switching between them, and Find / Ctrl+F / Ctrl+H work in both.
  • The gallery scrolls to follow the selected hit.
  • Fullscreen viewer: it follows the table selection, so stepping through results from the find bar changes the image. Left/right row arrows in the bottom-right corner (and ↑ / ↓) step through the view's rows, which during a search are the results. Rows with nothing to preview are skipped.

Storage

  • Vectors live in the project file, in a __visual_index table created on first write. The model that built them is recorded in __settings, and a different model clears them.
  • Paths are relative to the files folder, and a file counts as changed only when its length changes. A project copied with its files keeps its index.
  • Each batch is written as it's embedded.

Docs

  • New docs/visual-search.md: what it searches, installing the model, searching and the breadth slider, find similar items, indexing and where the index lives, searching inside linked PDFs, and the limits (one page or frame per file, no handwriting or printed text, English queries, never writes a cell).
  • Updated: docs/index.md, docs/grid.md (the two search toggles, and the bar following the view), docs/files-and-photos.md (the viewer's row arrows), and the README (feature bullet, privacy paragraph, docs list).

Crash fix: fast scrolling through thumbnails

This fixes a bug already on main, not one this branch introduced. Scrolling quickly through a large gallery could abort the app with a panic in gpui's DirectX atlas (directx_atlas.rs:255, Option::unwrap() on None). Windows reports the abort as STATUS_STACK_BUFFER_OVERRUN.

  • Cause:
    • preview::retain enforces the 192 MB thumbnail memory cap. It called cx.drop_image straight from the image loader, and gpui runs that loader while painting.
    • Freeing an image mid-paint could release the GPU texture behind a sprite that the same frame had already painted. Presenting that frame then looked up a texture that no longer existed.
    • The cap was also first-in-first-out: an image already held was never moved back. A card still on screen could be the "oldest" one freed.
  • Fix:
    • Every draw now moves the image to the back of the queue, so eviction takes whatever has been off screen longest.
    • retain never frees anything itself. It queues a release with window.on_next_frame, which runs before the next frame is laid out.
    • After freeing, release repaints every window. Dock panels are cached views that replay their last paint's sprites, and those could include a freed image.
  • Test: the eviction test now checks three things. Nothing is freed inside the frame that drew it. The release keeps the most recently drawn images. An image drawn again survives.

Decisions worth reviewing

  • Search narrowing in the delegate: set_search_rows overrides the view's rows. Searches always scan the rows the column filters allow (filtered_rows), not the rows the previous search left, so typing more can widen results again. An insert or delete drops the narrowing, because it shifts source indices.
  • candle instead of ONNX Runtime: no native runtime to ship, so all three CI platforms build it unchanged.
  • candle held at 0.9.2: from 0.10 it pulls in tokenizers and its C oniguruma build.
  • No FAISS or sqlite-vec: ranking is a dot product over the filtered rows, which takes milliseconds at collection scale.
  • Cutoff and staleness are ponytail: shortcuts:
    • The proportional cutoff was tuned on one collection.
    • Staleness is checked on length only.

Measured on this machine (16-core, CPU only)

  • Speed: 86 ms per image to embed, about 30 ms per text query.
  • Quality: on the Islandora sample photographs, "a building" and "a group of people standing outside" each ranked the right photo first. I checked both by eye.

Not done yet

  1. Optional components (Notion task): the model weights should install through that mechanism.
  2. Remote embedding endpoint: Infinity-style POST /embeddings, for institutions hosting their own. The Islandora server-side design is in the Notion doc "Repository-wide visual search for Islandora 2".
  3. Faster models: SigLIP 2 base is the candidate. MobileCLIP2's licence is research-only.
  4. OCR for scans without a text layer, and captioning / suggested subjects: both as staged drafts the archivist accepts.
  5. Agent bridge: QuerySource::Search still matches cells only. Extending it means updating AGENTS.md.

Known limits

  • PDF lock: reading a very long PDF holds the PDFium lock, which can pause gallery thumbnails (ponytail:).
  • Deleted files: their vectors stay in __visual_index.

Verification

  • Rebased on main at 0.5.0-beta.1, with no conflicts.
  • Local CI: ./scripts/ci.sh (fmt, clippy -D warnings, cargo test --workspace) passed on Windows.
  • Cross-platform lock: Cargo.lock resolves with --locked for x86_64/aarch64 macOS and x86_64 Linux.
  • PDF tests: ran against real PDFium, with no skips.
  • visual-search tests: ran against the downloaded weights.
  • App: a real 1,893-row project was indexed and searched by hand. That testing found an observer loop that froze the UI (fixed in badf6a3) and the ranking and cutoff problems addressed here.
  • Crash fix: reasoned from gpui's atlas, image and frame-loop source. The panic could not be reproduced in tests, because gpui's test renderer has no DirectX atlas.
  • Not clicked through after the latest changes: narrowing, the breadth slider, Gallery search, the viewer following the selection, storage in .qrate, and fast scrolling after the crash fix are covered by unit tests only.

🤖 Generated with Claude Code

@devnull03
devnull03 marked this pull request as ready for review September 15, 2026 22:14
Table search gains a linked-files toggle, on by default. Each row's linked
document is read once in the background, cached on disk beside the
thumbnails, and matched with the same case/word/regex rules as cells. A hit
lands on the cell that names the file.
Adds the visual-search crate: CLIP ViT-B/32 on the CPU through candle, a
hand-rolled CLIP tokenizer, a checksum-verified download of the pinned
weights, and an index of thumbnail embeddings kept on disk.

The table search bar gains a Visual toggle that ranks rows by how well their
linked file matches the query, offers the model download when it is missing,
and indexes linked files in the background. The row and gallery context
menu gains Find similar items.
Stat linked files and load the saved index before taking the lock the
search bar ranks through, and save the index every 40 batches so quitting
part-way through a first index keeps the work.
Taking the find-similar request through global_mut notified the same observer
again, spinning the UI thread once indexing touched the global. Also gives the
visual toggle a picture icon.
A visual or linked-file search now shows only its hit rows, visual ones best
first, until the search is cleared or closed. A breadth slider replaces the
fixed top 50 with a cut-off relative to the best hit. The find bar is drawn by
the views panel, so it stays up and keeps its search across Table and Gallery,
and the gallery scrolls to follow the selected hit. Replace is disabled during
a visual search. Linked-file text is now opt-in, since turning it on filters.
Vectors now live in a __visual_index table inside the .qrate, created on first
write, with the model that built them recorded in __settings. Paths are kept
relative to the files folder and staleness is judged on file length, so a
project copied with its files keeps its index. Each batch is written as it is
embedded, outside the index lock. Replaces the global visual-index.bin.
Up and down buttons, and the arrow keys, open the next row in the view's order
that has something to preview, and select it. During a search that is the next
result.
…ottom right

The viewer now opens the selected row's file whenever the selection moves, so
stepping through search results from the find bar changes the image. The row
buttons move from the top toolbar, where they repeated the find bar's, to
left and right arrows in the bottom-right corner.
…ling

The thumbnail memory cap called drop_image from the image loader, which gpui
runs while painting. Freeing an image there could release a GPU texture a sprite
earlier in the same frame still pointed at, and presenting that frame panicked
in the DirectX atlas. The cap was also first-in-first-out, so a card on screen
could be the one freed.

Images now move to the back of the queue every time they are drawn, and
releasing waits for the start of the next frame, then repaints every window so
no cached view replays a freed sprite.
Also gives the viewer's row arrows the same ghost buttons as its zoom controls.
@devnull03
devnull03 merged commit 343deb1 into main Sep 20, 2026
3 checks passed
@devnull03
devnull03 deleted the feat/visual-search branch September 20, 2026 04:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant