Natural-language + image search over 25,546 Swiss rental listings, with explainable ranking in DE / FR / IT / EN.
📦 Install · 🚀 Usage · 🏗 Architecture · 🧪 Dev · ☁ Deploy · 🎯 Challenge
Given a query like "3-room bright apartment in Zurich under 2,800 CHF with balcony, close to public transport", Robin:
- Extracts hard constraints with an LLM (rooms, price, city, required features).
- Gates retrieval through SQL — a listing that violates a hard constraint is structurally unable to reach the result set.
- Fuses four retrieval channels inside the gate — BM25 (FTS5), dense text embeddings, SigLIP-2 text→image, DINOv2 image→image — via Reciprocal Rank Fusion (k=60).
- Blends 8 normalized signals into a final score with templated, explainable reasons.
- Remembers each authenticated user's saves / likes / clicks to personalize the next query (opt-in).
Design doc: ARCHITECTURE.md. Built for the ETH/Uni Datathon 2026 in partnership with robinreal.
- Hard-filter gate — constraint violations are impossible, not merely unlikely
- 4-language query understanding — DE / FR / IT / EN, LLM-structured outputs
- Hybrid retrieval — BM25 + Arctic-Embed-L v2 + SigLIP-2 Giant + DINOv2 ViT-L/14
- Image-to-image search — upload any photo, find listings that look like it
- Commute-aware ranking — ~125 k real transit-minute pairs (r5py over SBB GTFS)
- Enrichment — 166-column side table, 100% coverage with explicit
UNKNOWNsentinels (no fabrication) - Session-scoped personalization — save / like / dismiss → memory-based re-ranking
- Explainable — every ranked listing carries a human-readable reason
Every result shows what we understood (LLM-parsed hard constraints), hard-filter pass/fail, the BM25 keywords found in the listing text, the real travel-minutes to user-picked landmarks (ETH, UZH, Altstadt, Zürich HB) from the GTFS commute graph, and the four fused channels with their contribution to the final score.
A horizontal bar chart exposes each signal's contribution to the final score (Words · Photos · Meaning · Your wishes). Hard-filter pass/fail and visual / semantic match scores sit in the footer. Love / Save / Bring back feed the personalization memory.
Every transit minute Robin shows is auditable — one click opens the Google Maps route from the listing address to the selected landmark. Users can cross-check our GTFS-derived r5py graph against Google's routing, so the commute signal isn't a black box.
flowchart LR
Q["/ user query + optional photo /"] --> LLM["LLM query understanding"]
LLM --> GATE{{"SQL hard-filter gate"}}
GATE --> BM25["FTS5 BM25"]
GATE --> DENSE["Arctic-Embed-L v2"]
GATE --> SIG["SigLIP-2 Giant"]
GATE --> DINO["DINOv2 ViT-L/14"]
BM25 --> RRF["Reciprocal Rank Fusion (k=60)"]
DENSE --> RRF
SIG --> RRF
DINO --> RRF
RRF --> BLEND["8-signal linear blend<br/>price · transit · POI · commute · …"]
MEM[("user memory<br/>saves · likes · dismisses")] -.opt-in.-> BLEND
BLEND --> OUT["top-K listings + reasons"]
classDef gate fill:#fee,stroke:#c00,color:#900
classDef ml fill:#eef,stroke:#44c,color:#228
class GATE gate
class LLM,DENSE,SIG,DINO,RRF,BLEND ml
See ARCHITECTURE.md for the full design, evidence, and references.
git clone https://github.com/<org>/Datathon_2026
cd Datathon_2026
cp .env.example .env # fill in your API keys
uv sync --group image_search # ~1 GB torch + transformers
# Get the pre-built dataset bundle (~2.5 GB, see docs/DATASET.md):
uv run --with gdown gdown 1rxYd17eX7C99AgNVlJft1QIFGtwogxX8 -O datathon2026_dataset.zip
unzip datathon2026_dataset.zip # into the repo root
uv run uvicorn app.main:app --reload
open http://localhost:8000/demoFull setup walk-through: docs/INSTALLATION.md.
Python 3.12 · FastAPI · SQLite (FTS5) · Snowflake Arctic-Embed-L v2 · Google SigLIP-2 Giant · Meta DINOv2 ViT-L/14 · r5py + SBB GTFS · OpenAI Structured Outputs · MCP Apps SDK · React + Vite (widget)
| Path | What |
|---|---|
app/ |
FastAPI server — routes, hard filters, ranker, auth, memory |
apps_sdk/ |
MCP bridge + React widget for ChatGPT / Claude Desktop |
enrichment/ |
Layer 1 — fill the 166-col listings_enriched side table · README |
ranking/ |
Layer 2 — 30 ranking signals + commute matrix + text embeddings · README |
image_search/ |
SigLIP-2 + DINOv2 indexes over 70,548 photos · README |
analysis/ |
v2 data audit (REPORT_v2.md, 19 plots, stats_v2.json) |
docs/ |
Installation · Usage · Development · Deployment · Reports |
tests/ |
34 integration test files · run: uv run pytest -q |
scripts/ |
Ops scripts — dataset install, DB migration, MCP smoke |
presentation/ |
Typst pitch deck |
| 📦 docs/INSTALLATION.md | Local setup from scratch |
| 🚀 docs/USAGE.md | API reference, demo UI, example queries |
| 📀 docs/DATASET.md | Pre-built dataset bundle install |
| 🧪 docs/DEVELOPMENT.md | Tests, rebuilding indexes, adding a signal |
| ☁ docs/DEPLOYMENT.md | Docker, Fly.io, Cloudflare tunnel, MCP |
| 🏗 ARCHITECTURE.md | Full system design with cited evidence |
| 🎯 docs/CHALLENGE.md | Organizer's problem brief (reference) |
| 📊 docs/query-evaluation-report.md | Query eval results |
Module reports (cross-check against the design doc):
docs/hard-filter-mvp-report.md |
Hard-filter gate + 123 tests |
docs/bm25-fts5-report.md |
SQLite FTS5 + BM25 tuning |
docs/visual-search-rrf-report.md |
SigLIP × BM25 RRF fusion |
docs/pipeline-flowchart.md |
Detailed scoring flowcharts |
- robinreal — challenge sponsor and listing corpus
- Comparis & SRED — additional listing sources
- SBB / OpenTransportData.swiss — GTFS feed (CC-BY)
- OpenStreetMap — Nominatim + Switzerland extract (ODbL)
- Snowflake — Arctic-Embed-L v2 (Apache 2.0)
- Google Research — SigLIP-2 (Apache 2.0)
- Meta FAIR — DINOv2 (Apache 2.0)
MIT — see LICENSE.



