Skip to content

Commit 203ad97

Browse files
committed
added reverse image search
1 parent 1d822ab commit 203ad97

7 files changed

Lines changed: 171 additions & 37 deletions

File tree

‎README.md‎

Lines changed: 33 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -19,29 +19,34 @@ https://github.com/user-attachments/assets/880c4caf-1367-4836-bc36-ef35524e1b2d
1919
https://github.com/user-attachments/assets/5ab6b907-5b08-4ef9-8ead-4b1b25510ce0
2020

2121

22+
#### reverse image search — upload a photo to find similar pages
23+
2224
#### can use OPT files to speed up indexing
2325
#### can export your index files to backup or share with others
2426
- on the web gui with admin mode enabled, or via cli
2527

2628
## Quick Start
2729

2830
1. Install [Docker](https://docs.docker.com/get-docker/)
29-
2. Configure:
31+
2. Clone the repo:
32+
```bash
33+
git clone https://github.com/pdfiles/pdfiles.git
34+
cd pdfiles
35+
```
36+
3. Configure:
3037
```
3138
cp .env.example .env
3239
# Edit .env — set DATA_PATH to your documents folder
3340
```
34-
3. Start:
41+
4. Start:
3542
```bash
36-
# Pull pre-built images (fastest)
37-
docker compose pull && docker compose up -d
38-
39-
# Or build from source
40-
./pdfiles.sh deploy
43+
./pdfiles.sh up
4144
```
42-
On Windows: `pdfiles.bat deploy`
45+
On Windows: `pdfiles.bat up`
46+
47+
5. Open http://localhost
4348

44-
4. Open http://localhost
49+
To stop: `./pdfiles.sh down` (or `pdfiles.bat down` on Windows)
4550

4651
First startup downloads the search model (~4 GB) and takes 2-3 minutes.
4752

@@ -50,12 +55,22 @@ First startup downloads the search model (~4 GB) and takes 2-3 minutes.
5055
| Command | Description |
5156
|---------|-------------|
5257
| `./pdfiles.sh up` | Start services |
58+
| `./pdfiles.sh update` | Pull latest images and restart |
5359
| `./pdfiles.sh down` | Stop services |
5460
| `./pdfiles.sh logs` | View logs |
5561
| `./pdfiles.sh status` | Health check |
56-
| `./pdfiles.sh backup` | Backup data |
62+
| `./pdfiles.sh backup` | Backup index to sqlite |
63+
| `./pdfiles.sh restore DIR` | Restore from a backup |
5764
| `./pdfiles.sh --help` | All commands |
5865

66+
### A note on updates
67+
68+
Qdrant data is stored in a docker volume that will persist `down` and `up`, reboots, etc.
69+
Updates should not affect this, which you can do with `pdfiles update`, but if you have spent a long time indexing files, it is always safest to `pdfiles backup` first.
70+
71+
**You do not need to reindex files to update**
72+
73+
5974
## Requirements
6075

6176
- Docker and Docker Compose
@@ -70,22 +85,13 @@ First startup downloads the search model (~4 GB) and takes 2-3 minutes.
7085
pdfiles scans through all of the pdfs that you mount in `DATA_PATH` and saves each page's essence in the form of vectors. when you type something in search, these words get turned into vectors; and then the two sets of vectors get compared. the resulting files are an ordered list of images that are closest to what you search. this is what enables "find similar photos" as well.
7186

7287

73-
## Architecture Overview
88+
## Architecture
7489

75-
```
76-
PDF Pages (517K)
77-
|
78-
v
79-
[Bouncer] ---> VISUAL / TEXT_ONLY / UNCERTAIN
80-
| (Tier 1: PyMuPDF text_ratio)
81-
| (Tier 2: Surya layout detection)
82-
v
83-
[Indexer] ---> ColQwen2.5 embed ---> 2D block pool ---> Qdrant (262x128d multi-vectors)
84-
|
85-
v
86-
[Librarian] ---> mean-pool to 1x128d ---> K-Means ---> auto-label clusters
87-
|
88-
v
89-
[Search UI] ---> text query ---> ColQwen2.5 ---> MaxSim ---> ranked results
90-
cluster browse ---> gallery of representative pages
90+
```mermaid
91+
flowchart TD
92+
PDF[PDF Pages] --> Bouncer --> Indexer
93+
Indexer --> ColQwen2.5 --> Qdrant[(Qdrant)]
94+
95+
Query[Text / Image / Similar] --> ColQwen2.5
96+
Qdrant -->|MaxSim| Results[Ranked Results]
9197
```

‎pdfiles.bat‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ if "%COMMAND%"=="" set "COMMAND=help"
99

1010
if "%COMMAND%"=="deploy" goto :deploy
1111
if "%COMMAND%"=="up" goto :up
12+
if "%COMMAND%"=="update" goto :update
1213
if "%COMMAND%"=="down" goto :down
1314
if "%COMMAND%"=="logs" goto :logs
1415
if "%COMMAND%"=="status" goto :status
@@ -57,6 +58,15 @@ echo.
5758
echo Frontend: http://localhost:80
5859
goto :eof
5960

61+
:update
62+
echo Pulling latest images...
63+
docker compose pull
64+
echo Restarting services...
65+
docker compose up -d
66+
echo.
67+
echo Frontend: http://localhost:80
68+
goto :eof
69+
6070
:down
6171
echo Stopping services...
6272
docker compose down
@@ -83,6 +93,7 @@ echo.
8393
echo Commands:
8494
echo deploy DATA_PATH First-time setup (creates .env, builds, starts)
8595
echo up Start services
96+
echo update Pull latest images and restart
8697
echo down Stop services
8798
echo logs [SERVICE] View logs
8899
echo status Health check

‎pdfiles.sh‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,24 @@ cmd_up() {
128128
print_urls
129129
}
130130

131+
cmd_update() {
132+
local force_mode=""
133+
for arg in "$@"; do
134+
case "$arg" in
135+
--cpu) force_mode="cpu" ;;
136+
--gpu) force_mode="gpu" ;;
137+
esac
138+
done
139+
140+
local compose_file
141+
compose_file=$(detect_compose_file "$force_mode")
142+
echo "Pulling latest images..."
143+
docker compose -f "$compose_file" pull
144+
echo "Restarting services..."
145+
docker compose -f "$compose_file" up -d
146+
print_urls
147+
}
148+
131149
cmd_down() {
132150
local clean=false
133151
for arg in "$@"; do
@@ -569,6 +587,7 @@ Usage: ./pdfiles.sh <command> [options]
569587
Commands:
570588
deploy [DATA_PATH] First-time setup (creates .env, builds, starts)
571589
up [--build] [--cpu] Start services
590+
update [--cpu] Pull latest images and restart
572591
down [--clean] Stop services (--clean removes volumes)
573592
logs [SERVICE] Tail logs
574593
status Health dashboard
@@ -598,6 +617,7 @@ shift 2>/dev/null || true
598617
case "$COMMAND" in
599618
deploy) cmd_deploy "$@" ;;
600619
up) cmd_up "$@" ;;
620+
update) cmd_update "$@" ;;
601621
down) cmd_down "$@" ;;
602622
logs) cmd_logs "$@" ;;
603623
status) cmd_status "$@" ;;

‎src/pdfiles/api.py‎

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import asyncio
12
import io
23
import logging
34
import threading
@@ -6,7 +7,7 @@
67
from pathlib import Path
78

89
import httpx
9-
from fastapi import FastAPI, HTTPException, Query
10+
from fastapi import FastAPI, HTTPException, Query, UploadFile, File
1011
from fastapi.middleware.cors import CORSMiddleware
1112
from fastapi.responses import StreamingResponse
1213

@@ -179,6 +180,40 @@ def search(
179180
]
180181

181182

183+
@app.post("/api/search/image")
184+
async def search_by_image(
185+
file: UploadFile = File(...),
186+
top_k: int = Query(10, ge=1, le=500),
187+
):
188+
ALLOWED_TYPES = {"image/jpeg", "image/png"}
189+
if file.content_type not in ALLOWED_TYPES:
190+
raise HTTPException(status_code=415, detail=f"Unsupported file type: {file.content_type}. Use JPEG or PNG.")
191+
192+
MAX_SIZE = 10 * 1024 * 1024 # 10 MB
193+
data = await file.read()
194+
if len(data) > MAX_SIZE:
195+
raise HTTPException(status_code=413, detail="File too large (max 10 MB)")
196+
197+
from PIL import Image
198+
199+
image = Image.open(io.BytesIO(data)).convert("RGB")
200+
results = await asyncio.to_thread(_searcher.search_by_image, image, top_k=top_k)
201+
return [
202+
{
203+
"page_id": r.page_id,
204+
"pdf_path": r.pdf_path,
205+
"source_path": str(_resolve_pdf_path(r.pdf_path)),
206+
"pdf_id": r.pdf_id,
207+
"page_index": r.page_index,
208+
"total_pages": r.total_pages,
209+
"volume": r.volume,
210+
"score": r.score,
211+
"point_id": r.point_id,
212+
}
213+
for r in results
214+
]
215+
216+
182217
@app.get("/api/search/similar")
183218
def search_similar(
184219
point_id: int = Query(..., description="Qdrant integer point ID"),

‎src/pdfiles/searcher.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,12 @@ def expand_query(query: str) -> str:
3939
return f"Photograph or image showing: {query}"
4040
return query
4141

42+
def search_by_image(self, image: "PIL.Image.Image", top_k: int = 10) -> list[SearchResult]:
43+
"""Search for pages visually similar to an uploaded image."""
44+
embeddings = self.embedder.embed_images([image])
45+
query_vectors = embeddings[0].tolist()
46+
return self.store.search(query_vectors, top_k=top_k)
47+
4248
def search(self, query: str, top_k: int = 10) -> list[SearchResult]:
4349
"""Search for pages matching a text query (single-query with expansion)."""
4450
expanded = self.expand_query(query)

‎web/src/App.jsx‎

Lines changed: 54 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import React, { useState, useEffect, useCallback, useRef } from 'react';
2-
import { Search, ArrowLeft, X, LayoutGrid, Loader2, Database, Download, ChevronLeft, ChevronRight, Scan } from 'lucide-react';
3-
import { searchPages, searchSimilar, getClusters, getClusterDetail, getStatus, startIndexing, getIndexStatus, pageImageUrl, exportSnapshot } from './api';
2+
import { Search, ArrowLeft, X, LayoutGrid, Loader2, Database, Download, ChevronLeft, ChevronRight, Scan, ImagePlus } from 'lucide-react';
3+
import { searchPages, searchSimilar, searchByImage, getClusters, getClusterDetail, getStatus, startIndexing, getIndexStatus, pageImageUrl, exportSnapshot } from './api';
44

55
function App() {
66
const [query, setQuery] = useState('');
@@ -36,6 +36,7 @@ function App() {
3636
});
3737
const [indexError, setIndexError] = useState(null);
3838
const pollRef = useRef(null);
39+
const fileInputRef = useRef(null);
3940

4041
// Connection status
4142
const [backendReady, setBackendReady] = useState(false);
@@ -156,6 +157,34 @@ function App() {
156157
searchContextRef.current = null;
157158
};
158159

160+
const handleImageSearch = useCallback(async (file) => {
161+
setLoading(true);
162+
setError(null);
163+
setLightboxIndex(null);
164+
setCurrentPage(1);
165+
setActiveClusterId(null);
166+
setActiveCluster(null);
167+
setClusterPages([]);
168+
searchContextRef.current = { type: 'image' };
169+
setQuery(`Image: ${file.name}`);
170+
try {
171+
const results = await searchByImage(file, FETCH_BATCH);
172+
setSearchResults(results);
173+
setFetchedTopK(FETCH_MAX); // disable load-more — file is ephemeral
174+
} catch (e) {
175+
setError(e.message);
176+
setSearchResults([]);
177+
} finally {
178+
setLoading(false);
179+
}
180+
}, []);
181+
182+
const handleFileChange = (e) => {
183+
const file = e.target.files?.[0];
184+
if (file) handleImageSearch(file);
185+
e.target.value = '';
186+
};
187+
159188
const handleExport = async () => {
160189
setExporting(true);
161190
try {
@@ -229,7 +258,7 @@ function App() {
229258
}
230259
// Need more results — fetch next batch and advance
231260
const ctx = searchContextRef.current;
232-
if (!ctx || fetchedTopK >= FETCH_MAX) return;
261+
if (!ctx || ctx.type === 'image' || fetchedTopK >= FETCH_MAX) return;
233262
const nextTopK = Math.min(fetchedTopK + FETCH_BATCH, FETCH_MAX);
234263
setLoadingMore(true);
235264
try {
@@ -286,20 +315,36 @@ function App() {
286315
</div>
287316
<input
288317
type="text"
289-
className="block w-full pl-4 pr-12 py-4 bg-transparent border-none text-white placeholder-gray-600 focus:outline-none focus:ring-0 text-lg"
318+
className="block w-full pl-4 pr-20 py-4 bg-transparent border-none text-white placeholder-gray-600 focus:outline-none focus:ring-0 text-lg"
290319
placeholder="Search documents..."
291320
value={query}
292321
onChange={(e) => setQuery(e.target.value)}
293322
onKeyDown={handleKeyDown}
294323
/>
295-
{query && (
324+
<div className="absolute inset-y-0 right-0 pr-4 flex items-center gap-2">
325+
{query && (
326+
<button
327+
onClick={clearSearch}
328+
className="cursor-pointer text-gray-500 hover:text-gray-300"
329+
>
330+
<X className="h-5 w-5" />
331+
</button>
332+
)}
296333
<button
297-
onClick={clearSearch}
298-
className="absolute inset-y-0 right-0 pr-4 flex items-center cursor-pointer text-gray-500 hover:text-gray-300"
334+
onClick={() => fileInputRef.current?.click()}
335+
className="cursor-pointer text-gray-500 hover:text-indigo-400 transition-colors"
336+
title="Search by image"
299337
>
300-
<X className="h-5 w-5" />
338+
<ImagePlus className="h-5 w-5" />
301339
</button>
302-
)}
340+
<input
341+
ref={fileInputRef}
342+
type="file"
343+
accept="image/jpeg,image/png"
344+
className="hidden"
345+
onChange={handleFileChange}
346+
/>
347+
</div>
303348
</div>
304349
</div>
305350

‎web/src/api.js‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,17 @@ export async function searchSimilar(pointId, topK = 10) {
4545
return res.json();
4646
}
4747

48+
export async function searchByImage(file, topK = 10) {
49+
const form = new FormData();
50+
form.append('file', file);
51+
const res = await fetch(`${API_BASE}/search/image?top_k=${topK}`, {
52+
method: 'POST',
53+
body: form,
54+
});
55+
if (!res.ok) throw new Error(`Image search failed: ${res.status}`);
56+
return res.json();
57+
}
58+
4859
export function pageImageUrl(pointId) {
4960
return `${API_BASE}/page/${pointId}/image`;
5061
}

0 commit comments

Comments
 (0)