Semantic search + grounded chat over the posters.science
corpus — 31,363 machine-actionable scientific posters (Zenodo + Figshare, DataCite 4.7
schema, which adds the native Poster resource type). Built for demo at BOSC 2026 / CollaborationFest.
Retrieval is pgvector HNSW over Alibaba-NLP/gte-large-en-v1.5 embeddings (1024-dim, one vector per poster) fused with Postgres full-text search; answers are generated by a small local LLM (ollama) grounded in the retrieved posters with inline DOI citations. No query ever leaves the host.
| Where | How | Guide |
|---|---|---|
| Laptop / any Docker host | docker compose — one command, GPU-optional, fits an 8GB-VRAM laptop |
LOCAL-DEPLOY.md |
| Build host (GPU workstation) | native pipeline (flatten → embed → load → index) + systemd services | Quickstart below |
| Public URL (conference) | relay VPS + outbound reverse-SSH tunnel, no home ports opened | deploy/EXPOSE-via-relay-vps.md |
The database itself (~185MB dump) is transferred out of band — too large for git.
Produce it on a build host with scripts/dump.sh, load it on a target with scripts/restore.sh.
poster *_complete.json ──> flatten_posters.py ──> embed_posters.py (GTE, fp32, GPU)
│ .npz shards
v
pgvector/pgvector:pg15 <── load_posters.py
(HNSW halfvec, m=16, + init/03_indexes.sql
ef_construction=128)
│ read-only role
v
static chat UI <── FastAPI app.py ──> ollama (small instruct model, e.g. qwen3:4b)
/api/search /api/chat (SSE)
Everything binds to 127.0.0.1; public exposure is a separate reverse-proxy layer.
| Path | Purpose |
|---|---|
up_db.sh / down_db.sh |
launch/remove the pgvector container (plain docker run) |
init/02_schema.sql |
posters table (metadata + vector(1024) + generated tsvector) |
init/03_indexes.sql |
post-load HNSW/GIN/btree index build |
app/flatten_posters.py |
canonical corpus → sanitized JSONL; license classification via the corpus repo's license_policy.py |
app/embed_posters.py |
resumable GPU embedding into .npz shards (VRAM-capped, device-gated) |
app/load_posters.py |
idempotent bulk upsert into Postgres |
app/app.py |
FastAPI: hybrid RRF retrieval, SSE chat, per-session rate limits |
app/static/index.html |
dependency-free chat UI (escape-by-default rendering) |
deploy/*.service |
systemd --user units for the ollama instance and the API |
cp .env.example .env && $EDITOR .env && chmod 600 .env
./up_db.sh # pgvector container on 127.0.0.1:$POSTERBOT_DB_PORT
# create roles once, then apply schema (see init/02_schema.sql header)
python app/flatten_posters.py --out scratch/posters.jsonl
python app/embed_posters.py --in scratch/posters.jsonl
python app/load_posters.py
docker exec -i posterbot-db psql -U posterbot_owner -d posters < init/03_indexes.sql
systemctl --user enable --now posterbot-ollama posterbot-api
open http://127.0.0.1:8722Python deps (Python 3.12): torch, sentence-transformers==3.4.1,
transformers==4.57.1, psycopg2-binary, fastapi, uvicorn, httpx.
Poster full text is served to the model/UI only for records whose rightsList
classifies as allowed under the corpus license policy (CC-BY family, CC0, permissive
software licenses, …). Everything else — including upstream _license_blocked records,
whose extracted content is already stripped — contributes catalog metadata only
(title, abstract, authors, DOI link). Poster text is OCR-derived and treated as
untrusted data end-to-end: the LLM has no tools, citation links are constructed
server-side from the database, and the UI renders exclusively via textContent.