FastAPI prototype for retrieval-augmented paper annotation. Given a query (a question or proxy for one), it scores all chunks of a paper and highlights the most relevant passages in a rendered paper view.
cd rag_prototype
pip install fastapi "uvicorn[standard]" jinja2
#if not already there also: sentence-transformers
uvicorn main:app --reload --port 8000Open http://localhost:8000 → redirects to the paper view. Enter a query, press Score (or Enter).
rag_prototype/
├── config.py # Paper registry and path constants
├── paper_loader.py # Loads OCR + chunk JSON; builds block→chunk mapping
├── renderer.py # OCR sections → annotated HTML
├── main.py # FastAPI routes
├── retrieval/
│ ├── base.py # Chunk / ScoredChunk dataclasses + Scorer protocol
│ ├── cross_encoder.py # CrossEncoder implementation (Qwen3-Reranker-0.6B)
│ └── __init__.py # Swap retrieval backend here — nothing else changes
├── templates/paper.html # Two-pane layout (Jinja2)
└── static/
├── style.css # Layout, highlight gradient, result cards
└── app.js # Score flow, highlighting, bidirectional navigation
retrieval/base.py defines a Scorer protocol with a single method:
def score(self, query: str, chunks: list[Chunk]) -> list[ScoredChunk]: ...The active backend is set in retrieval/__init__.py. Switching from cross-encoder to bi-encoder (or a hybrid) means changing one import.
Each OCR block belongs to at most one chunk. paper_loader.py builds a dict[block_id → chunk_id]. Blocks carry a single data-chunk-id attribute; app.js reads it directly to apply the block's score.
The normalized OCR JSON (RAG_research_paper/data/normalized/) has a clean section → content-block hierarchy. renderer.py walks this and emits HTML where every block carries:
data-block-id— the OCR pipeline's stable identifierdata-chunk-id— the chunk this block belongs to (empty if unmapped)
Tables use the HTML already present in table_body; figures and table images are served via /images/{paper_key}/{filename}.
After scoring, app.js sets a CSS custom property --block-hl on each relevant block (interpolating between #e0f0ff and #1a6fa8 by normalised score). The server is not involved — the paper HTML stays in the DOM and only styles change.
Clicking a chunk card in the results panel scrolls the paper to that chunk's first block. Clicking any block in the paper scrolls the results panel to the corresponding card.
Double-clicking any block in the paper opens a modal showing the raw JSON for that chunk (chunk_id, text, section path, page span, source_block_ids). Score data is merged in when scoring has been run. This works without scoring, making it useful for debugging the chunker directly.
Add an entry to the PAPERS dict in config.py:
"my_paper_key": {
"ocr_json": REPO_ROOT / "RAG_research_paper/data/normalized/my_paper_key.json",
"chunks_json": REPO_ROOT / "RAG_research_paper/data/chunks/my_paper_key.json",
"images_dir": REPO_ROOT / "research_paper_ocr/data_ocr/mineru_raw/my_paper_key/.../auto",
}The paper is then available at /papers/my_paper_key.
| Prototype component | Django equivalent |
|---|---|
config.py PAPERS dict |
PaperDocument / PaperChunk model query |
paper_loader.py |
QuerySet + serialisation layer |
renderer.py |
Django template tag or mark_safe() helper |
/api/{key}/score endpoint |
HTMX endpoint called from annotation view |
retrieval/ module |
Django service, model loaded in AppConfig.ready() |
| Image serving | Django MEDIA_URL / CDN |
The Scorer protocol, Chunk, and ScoredChunk dataclasses are pure Python and import into Django unchanged.