LLM

Question answering over long technical documents that refuses rather than fabricates

Answers about long technical handbooks with citations checked against the exact retrieved text

Sole developer

The problem

People ask AI assistants about long handbooks, and chat models guess and fabricate sources. An answer is only useful if it points to the exact page and span it came from.

Team: Personal project. Timeline: October 2026.

What changed

Answer accuracy
0.86
19 of 22 questions on the development answer set, Gemini 3.5 Flash-Lite, judged by script (results/answer_eval.json); 22 questions is a small set
Held-out recall@5
0.83
Hybrid retrieval plus reranker on 6 held-out questions over 2 FAA documents (results/retrieval_heldout_eval.json); MRR and nDCG@5 also 0.83
Fine-tuned embeddings
No gain
Baseline: MRR 0.92 without fine-tuning
bge-small fine-tuned on 12 pairs dropped MRR to 0.83 on the same 6 held-out questions (results/finetune_retrieval.json); kept in history, not wired in

Architecture

Architecture: SpecPilotWeb UI, Plan-and-act agent and tools, Postgres (pgvector + full-text), Local cross-encoder reranker, Hosted LLM, Deterministic citation check, FastAPI (/search, /ask over SSE). Connections: Web UI to FastAPI (/search, /ask over SSE); FastAPI (/search, /ask over SSE) to Plan-and-act agent and tools; Plan-and-act agent and tools to Postgres (pgvector + full-text); Plan-and-act agent and tools to Local cross-encoder reranker; Plan-and-act agent and tools to Hosted LLM; Plan-and-act agent and tools to Deterministic citation check; Deterministic citation check to FastAPI (/search, /ask over SSE).keyword + dense, fused by RRFanswer or refusalWeb UIclientPlan-and-act agent and toolsservicePostgres (pgvector + full-text)data storeLocal cross-encoder rerankermodelHosted LLMexternalDeterministic citation checkserviceFastAPI (/search, /ask over SSE)gateway

Request flows

  1. Retrieve and rerank: Web UI → FastAPI (/search, /ask over SSE) → Plan-and-act agent and tools → Postgres (pgvector + full-text)
  2. Answer only with verified citations: FastAPI (/search, /ask over SSE) → Plan-and-act agent and tools → Hosted LLM
  3. Verify before answering: Plan-and-act agent and tools → Deterministic citation check → FastAPI (/search, /ask over SSE)

Architecture: SpecPilot

Components

Request flows

Choose a component to focus it, or a flow to watch its requests. In the view, drag or use the arrow keys to turn it and + or − to zoom. Escape closes.

Decision 1 of 4

How should passages be retrieved?

Options
Keyword search · Dense vectors · Both in parallel · fused by reciprocal rank and reranked
Chose
Postgres full-text and pgvector in parallel, fused with reciprocal rank fusion (k=60), then a local cross-encoder
Trade-off
Every query pays for a reranker pass.
Evidence
On the 18-question development set, keyword-only recall@5 was 0.28–0.33; the reranker raised dense recall@5 from 0.94 to 1.00 on fixed chunking.

Decision 2 of 4

Is a dedicated vector database worth it?

Options
Qdrant · Weaviate or Milvus alongside Postgres · Postgres alone with pgvector
Chose
One Postgres instance holding vectors, full-text columns, chunks, metadata and results
Trade-off
Past about a million chunks a dedicated vector index would be worth revisiting.
Evidence
The corpus is tens of documents and well under 100,000 chunks, so one store keeps deploys simple and filtering transactional.

Decision 3 of 4

How are citations trusted?

Options
Ask another model to check them · Check every quoted span mechanically
Chose
Every quoted span must appear in the retrieved text after normalisation; one repair pass, then refusal
Trade-off
A few valid answers are refused because the model paraphrased its quote.
Evidence
The failure tests (provider errors, malformed tool arguments, a dead database, a tainted PDF) all end in a refusal or degraded answer, never a fabricated one.

Decision 4 of 4

Should the embedding model be fine-tuned?

Options
Fine-tune bge-small on domain pairs · Keep the base model
Chose
Keep the base model
Trade-off
The experiment's code and result stay in history without being used.
Evidence
Fine-tuning on 12 pairs lowered held-out MRR from 0.92 to 0.83.

Checking each citation against the retrieved text

def verify_citation(citation: Citation, source_pages: dict[tuple[str, int], str]) -> bool:
    """True if the quoted span appears (whitespace/case-normalised) in the page's retrieved text."""
    source = source_pages.get((citation.document, citation.page))
    if source is None:
        return False
    return _norm(citation.quoted_span) in _norm(source)


def verify_answer(
    answer: AnswerContract, source_pages: dict[tuple[str, int], str]
) -> tuple[AnswerContract, list[str]]:
    """Return (contract, issues). Drop unverifiable citations; refuse if none
    remain on a non-refusal answer."""
    if answer.refusal:
        return answer, []
    valid: list[Citation] = []
    issues: list[str] = []
    for citation in answer.citations:
        if verify_citation(citation, source_pages):
            valid.append(citation)
        else:
            issues.append(f"span not found verbatim at {citation.document}:{citation.page}")

The check compares strings, not opinions. A citation either points at text that was actually retrieved or it is dropped, and an answer left with no valid citation becomes a refusal.

Questions?

Read the full write-up