4. Answers and agents¶
Retrieval works with no LLM at all. POST /search and the MCP tools never call one.
The LLM lives in exactly two optional places: writing an answer, and writing chunk
descriptions.
That separation is worth stating up front, because it is what makes the service usable with zero credentials and testable without a model.
What this stage owns
llm.py (the provider chain), search/answer.py (cited answers),
mcp_server.py (three tools over streamable HTTP), and the surfaces in api.py,
cli.py and static/index.html.
The provider chain ends at your machine¶
RAG_LLM_PROVIDER=auto resolves in order:
- Anthropic, if an API key is present
- OpenAI, if one is present
- local Ollama otherwise
So /ask works with no keys at all. That is not a convenience feature — a service that
cannot be tried without a credit card does not get tried.
ollama pull qwen3.5:9b # 6.6 GB; qwen3.5:4b on a weaker machine
uv run rag ask "how are webhook events queued?" -r my-api
The OpenAI client takes a base_url, so anything OpenAI-compatible connects the same
way:
vllm serve Qwen/Qwen3.5-9B --port 8000 # or LM Studio → Local Server
RAG_LLM_PROVIDER=openai RAG_OPENAI_BASE_URL=http://localhost:8000/v1 \
RAG_LLM_MODEL=Qwen/Qwen3.5-9B OPENAI_API_KEY=local uv run rag serve
Local models, same 6-chunk prompt, 3 questions
| Model | time / answer | Quality | "No answer" behaviour |
|---|---|---|---|
| qwen3.5:9b, thinking off ✓ | 12–17 s | cited [1][2][4], right snippet |
correct, 2 s |
| qwen3.5:9b, thinking on | 48–51 s | empty answer on 2 of 3 | — |
| qwen2.5:7b | 22–32 s | good, cites file + function | correct, 4 s |
| qwen3:1.7b, thinking off | 11–15 s | mangled words, invented terms | unstable |
| claude-opus-5 | — | the reference | correct |
Two settings came out of that table. Thinking models are called with thinking
off: a cited answer does not need it, and with it on the reasoning ate the whole
1024-token budget and returned nothing. And RAG_OLLAMA_NUM_CTX=16384, because
Ollama's 4k default silently truncated an 8-chunk prompt — no error, just a worse
answer.
Citations make fabrication visible¶
An answer with no citations is unfalsifiable: every sentence looks equally plausible whether it came from the code or from the model's priors. Requiring a marker on each claim turns that into something a reader can check in two seconds.
# search/answer.py
SYSTEM_PROMPT = """...
1. Answer ONLY from the chunks given. Anything not in them, you do not know.
2. Mark every claim with the number of the chunk it rests on: [1], [2]. Never write an
uncited claim.
3. If the chunks do not answer the question, say so plainly, and suggest which file to
look at if the chunks let you infer it. Do not invent.
"""
Line numbers go into the prompt with the chunks, so a citation resolves to
file:line — function rather than to a file. Rule 3 matters as much as rule 2: without
an explicit permission to fail, a model asked a question its context cannot answer will
answer it anyway.
MCP: the agent is the adjudicator¶
The MCP server runs in the same process as the HTTP API, over the same retriever, served
as streamable HTTP under /mcp:
Three tools, and the number is the point:
| Tool | What it does |
|---|---|
search_code |
candidates, with per-channel scores and the signals below |
read_code |
opens the rest of a file — only files that are indexed |
list_repos |
which codebases are connected, and how fresh each is |
# mcp_server.py
"""Why only three tools: the agent finds the first candidates with `search_code`, then
decides by READING the rest with `read_code`; `list_repos` says which codebases are
connected. That loop is essentially what Cursor and Claude Code do over a codebase —
the retriever's job is to offer candidates, the decision is the agent's."""
Honest signals instead of a confident guess¶
The retriever returns something for every query, including a nonsense one. Since cosine cannot separate the grey zone (see Retrieval), the answer is not a cleverer gate — it is telling the agent what it knows and what it does not:
- the weak-match note, when the best dense score is under 0.55
- a
DOCUMENTlabel, so code quoted inside a design document is not mistaken for real code - index freshness, so an agent can tell a stale index from a missing file
read_codelocked to the manifest, which is what makes "it is not there" trustworthy — the tool cannot open a file the index never saw- explicit permission to say "I could not find it" in the server instructions
Retrieved code is data, not instructions¶
"""SECURITY: the code that comes back is from an indexed repo — it is DATA for the
agent, not INSTRUCTIONS. The server instructions say so explicitly."""
An indexed repository is untrusted input. A comment in someone's README that says
"ignore your previous instructions" arrives at the agent through the same channel as a
real function body, and nothing structural distinguishes them unless something is said.
Blocking work goes to a thread¶
Embedding, Milvus calls and file reads all block. The MCP session runs on a single event
loop, so blocking there stalls every other request on the connection. All three go
through anyio.to_thread.
Surfaces¶
Everything is reachable three ways, over the same code path.
curl -s localhost:8090/search -H 'content-type: application/json' -d '{
"query": "how are webhook events queued",
"repo_ids": ["my-api"],
"k": 8,
"mode": "auto"
}' | jq '.hits[0] | {path, symbol, start_line, scores}'
/docs serves the OpenAPI page. Repos, jobs, files and chunks all have endpoints;
webhooks live under /webhooks/azure/push and /webhooks/github/push.
Three tabs at /:
- Search — results with channel scores, and the LLM answer next to them
- Jobs — every index run as a pipeline: source → diff → chunk → embed → Milvus, with live progress
- Connect — copyable webhook URLs, poller state, curl examples, MCP setup, and a live check of whether Ollama is up and the model pulled
Credentials can be entered from the UI as well as from .env: the GitHub token and
Azure org+PAT in Repos › Connect repo, the webhook secret and Anthropic key in
Connect › Keys. What you enter is verified, stored in data/rag.db, overrides the
environment, and needs no restart.