Skip to content

Repository files navigation

UniQ — York University Policy Assistant

A hybrid-retrieval RAG assistant over a 216-document York University policy corpus, that cites its sources and refuses when the corpus can't support an answer. Runs fully locally — no API keys, no cloud, $0.

Asking a policy question and getting a cited answer

The GIF is sped up 8×. Real measured latency is 3.2 s of retrieval plus local CPU generation, which is slow and honestly reported below rather than hidden. Everything shown is the running system answering real queries — retrieval, citations and refusals are genuine, nothing is mocked up. Reproduce with bash scripts/run_demo.sh (needs Ollama).

Grounded answer, with citations Honest refusal when the corpus can't support an answer
Answer with citations Refusal on an out-of-scope question

Architecture

The default path is linear: retrieve with both a lexical and a dense index, fuse the ranked lists, rerank, gate, then answer with citations. The adaptive branches exist behind flags and are off by default — see What still doesn't work.

flowchart TD
    Q[User query] --> BM25["BM25 lexical · top-20"]
    Q --> VEC["Vector dense · top-20"]
    BM25 --> RRF["RRF fusion · k=60"]
    VEC --> RRF
    RRF --> CE["Cross-encoder rerank · top-6"]
    CE --> GATE{"Refusal gate<br/>score ≥ threshold?"}
    GATE -->|yes| LLM["LLM answer + citations"]
    GATE -->|no| REF["Refuse: insufficient evidence"]

    style GATE fill:#009E73,color:#fff
Loading

Every stage emits a ScoredChunk carrying its own ScoreType, because BM25, cosine, RRF and cross-encoder scores are on four incompatible scales. The gate picks its threshold from the score's type; comparing them naively refused 100% of queries (see DECISIONS.md ADR-11).

Component Technology
LLM llama3.2:3b via Ollama (local, CPU)
Embeddings all-MiniLM-L6-v2 via SentenceTransformers (local, ~90 MB)
Reranker cross-encoder/ms-marco-MiniLM-L-6-v2 (local, ~117 MB)
Vector store ChromaDB (persistent, cosine HNSW)
BM25 bm25s (persisted inverted index)
Web Next.js 16 + Auth.js + Prisma/SQLite
API FastAPI (SSE streaming), internal-only
Deployment Docker Compose · CI via GitHub Actions

Results

Hand-written, paraphrased eval set. Corpus: 216 documents → 2,864 chunks. Methodology and caveats: docs/EVALUATION.md.

These numbers are also reproduced, and extended to 248 questions with component-level failure attribution, by a separate harness: york-rag-eval. It runs this system behind an adapter, splits every failure into a retrieval or generation cause, and gates regressions in CI.

Metric Value n 95% CI
Hit-rate@5 (a chunk of the right policy in the top 5) 78% 36 [64%, 92%]
End-to-end success (hit and gate open) 44% 36 [30%, 60%]
False refusal (retrieved, but gate closed) 36% 36 [22%, 52%]
Reranker demotion (in pool, ranked out of top-5) 17% 36 [8%, 32%]
Retrieval miss (neither retriever had it) 3% 36 [0%, 14%]
Out-of-scope refusal 70% 40 [55%, 82%]

Retrieval is not the bottleneck — 35 of 36 questions have the correct policy somewhere in the candidate pool. The refusal gate and the reranker account for essentially all of the failures.

Per-stage ablation, hit-rate@5 (data/eval/ablation_report.md):

Mode Hit-rate@5 95% CI
bm25_only 50% [33%, 67%]
vector_only 78% [64%, 92%]
hybrid_rrf 78% [64%, 89%]
full_rerank 78% [64%, 92%]

Hybrid retrieval does not measurably beat dense retrieval alone here. Vector, hybrid and reranked all land on the same number, and BM25 alone is 28 points behind. In the failure analysis, 7 failing questions had the answer only in the vector candidate list and 0 only in the BM25 list. On paraphrased queries over this corpus the lexical half is carrying its weight in latency, not in results. It is retained because it is nearly free (0.9 ms) and lexical matching is the plausible failure mode for exact policy-number or defined-term lookups, which this eval set does not probe — but that is a hypothesis, not a measurement.

At these sample sizes the intervals are tens of points wide. Differences narrower than the intervals are not established, and nothing here should be read as a precise figure.

Retrieval costs 3.2 s per query on CPU, of which the cross-encoder is 98% (BM25 1.0 ms, vector 60 ms, RRF 0.2 ms, cross-encoder 3,091 ms). LLM generation is timed separately and dominates end-to-end. Full breakdown: data/eval/latency_report.md.

Quickstart

cp .env.example .env            # set API_SHARED_SECRET, or export ALLOW_UNAUTHENTICATED=1
pip install -e ".[dev]" && ollama pull llama3.2:3b
python -m scripts.ingest        # index data/documents/ (~10 min on CPU)
python -m api.main &            # RAG service on 127.0.0.1:8000
cd web && npm ci && npx prisma migrate dev && npm run dev   # UI on :3000

Docker: docker compose up --build (only the web app publishes a host port). Legacy Streamlit UI: SERVICE=streamlit, or docker compose --profile streamlit up.

The bug that invalidated everything

Chunks were sized at 1000 tokens of cl100k_base — OpenAI's tokenizer, which no model in this system uses. The embedder truncates at 256 WordPiece tokens, so most of every chunk was silently discarded before embedding and could never be retrieved. Nothing errored. Nothing warned. The index was just quietly wrong, and so was every number measured on it.

Fixing it (size chunks with the embedding model's own tokenizer; Settings now refuses to construct if chunk_size exceeds the embedder's usable context) moved end-to-end success from 28% to 44% — and reversed both of this project's previously published "negative results":

Claim, pre-fix Post-fix reality
"The cross-encoder is net-negative for recall" No longer negative — but the gain is inside the noise (78% vs 78% hit-rate@5)
"The fitted gate threshold degenerates to τ ≈ 0" It doesn't; τ is meaningful on every fold

Both were artefacts. A cross-encoder scoring the first third of each chunk really does demote correct answers, and a signal that weak really does have nothing to threshold on — the measurements were faithfully reporting a broken pipeline. Note the correction cuts both ways: the reranker is no longer harmful, but that is not the same as useful, and the honest current answer is "we can't tell at this sample size". Every number here was regenerated rather than carried forward.

What still doesn't work

  • Out-of-scope refusal is 70%, not 100%. The earlier "100%" came from 6 probes. On 40 harder ones — other universities' policies, external legislation, plausible-sounding but non-existent York policies, injection attempts — the gate answers about a third of questions it has no evidence for.
  • BM25 contributes nothing measurable on this eval set (see the ablation above). Hybrid retrieval is a defensible default, not a demonstrated win.
  • Fitting the cross-encoder threshold is worse than leaving it at 0.3 (Youden J 0.048 vs 0.200). The vector similarity signal is the one that beats the baseline (J 0.282). That's a lead, not a shipped change.
  • Adaptive reranking is now indistinguishable from plain reranking — identical to three decimals. It adds a branch and a flag for no measured benefit, so it stays default-off.
  • Everything above has overlapping confidence intervals. At n=36 / n=40 these are directional readings, not settled facts.

Limitations

Evaluation. 36 in-scope + 40 out-of-scope questions — small, wide intervals, every figure an estimate. Ground truth is document-level, so the metric asks "did a chunk of the right policy surface?", not "did the best chunk surface?"; recall@k is capped by that label style and must not be compared across chunk sizes (docs/EVALUATION.md).

Auth. No email verification (emailVerified is in the schema and never written) and no password reset. Sessions are revocable — each JWT is paired with a Session row that is checked on every request, so "sign out everywhere" invalidates live tokens immediately (web/lib/sessions.ts). The cost is one indexed lookup per authenticated request.

Operations. Rate limiting and the inference cap are both in-process, so more than one web replica needs Redis. TRUSTED_PROXY_DEPTH defaults to 0, meaning x-forwarded-for is ignored — set it to the number of proxies you control or rate limiting keys on "unknown". Single-process, single-model inference with no batching; concurrency is capped at 2 in-flight retrievals, past which the API returns 503.

Corpus. Scanned PDFs are not OCR'd — pages with no extractable text are logged and skipped. RAGAS evaluation needs an OpenAI key and is the one part of the project that is not $0 / local; it is not installed by default.

Citation rendering. The model is instructed to cite inline as [Source: <filename>, page N, <chunk_id>], which is verbose and duplicates the citation panel below the answer — visible in the screenshot above. It is that explicit because it makes citations checkable in the eval; a friendlier format is a prompt change that would need the grounding behaviour re-measured, so it has not been made casually.

Development

Tests, linting, and contribution workflow: CONTRIBUTING.md. Design rationale and the decisions that were reversed after measurement: DECISIONS.md.

src/ retrieval engine (chunking, bm25, vectorstore, retrieval, reranker, llm, evaluation) · api/ internal FastAPI service · web/ Next.js app, the only host-facing service · app/ legacy Streamlit UI · scripts/ ingestion and every benchmark · data/eval/ labels, result JSONs, generated reports.

The three places a plausible-looking change silently breaks the system: src/chunking/chunker.py (chunk length is denominated in the embedding model's tokenizer), src/retrieval/scored.py (score scales are not interchangeable), and src/config/settings.py (which enforces the first of those at startup).

License

MIT — see LICENSE. The York University policy documents under data/documents/ are York's property and are not covered by it.

About

Hybrid BM25 + dense retrieval RAG assistant over a 216-document York University policy corpus. Cites sources, refuses when the corpus cannot support an answer, runs fully locally.

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages