Two doors lead into the same retrieval core. POST /search arrives
with a repository path and exercises both legs of the hybrid.
POST /chat arrives without one, and the keyword leg returns
nothing. Everything downstream — fusion, reranking, confidence — is identical.
02.1The two entry points
Card 02-aroutes/search.py:17
POST /search
Reads app.state.cross_repo_search.
CrossRepoSearch.search() lists every repository whose status is
ready, checks the clone still exists on disk, and calls
HybridSearchService.search() once per repository with
project_path=repo_path and
repo_ids=[repo.id]. Results are concatenated, sorted by score
and truncated to limit.
Card 02-broutes/chat.py:192
POST /chat
Uses a module-global HybridSearchService installed at startup
and calls hs.search(query, repo_ids=repos, limit, search_type).
project_path is never supplied, so the Zoekt call falls through
its two guards and returns an empty list. Chat retrieval is semantic-only in
practice.
defsearch(self, query, project_path: Optional[Path] = None, limit: int = 50):
if self.is_available() and project_path:
return self._search_zoekt(query, project_path, limit)
elif self.is_ripgrep_available() and project_path:
return self._search_ripgrep(query, project_path, limit)
else:
logger.warning("No search backend available")
return []
Both branches require project_path. A caller that
omits it gets an empty keyword list and a warning line in the log, not an
error. Combine that with the router's decision to send high-confidence symbol
lookups down SearchMode.KEYWORD_ONLY
(query_router.py:263) and the exact query type the routing exists
to accelerate — “find validateToken” — returns zero chunks
through /chat.
02.2Lifecycle of one query
Figure 02.1 — Query lifecycle
The keyword leg issues up to three subprocess calls per repository per query,
each with a 30-second ceiling. The semantic leg issues one embedding call,
usually served from cache, and one in-process FAISS scan.
02.3Query routing
QueryRouter is a deterministic classifier — thirty-eight regular
expressions in three groups, plus two heuristic boosts. It runs on the
/chat path only; /search takes its mode from the
request body. The decision it returns is echoed back to the client verbatim in
search_metadata, including the human-readable
reasoning string built at query_router.py:283.
Figure 02.2 — Router decision tree
Pattern weights are literals in the source; the highest-weighted match in a
group wins, so a query matching several patterns scores as its strongest
single match rather than accumulating.
02.4Retrieval-augmented answering
Route — QueryRouter.route() yields the mode and the limit multiplier.
Add repository context — if the type is
ARCHITECTURE, or a second nine-pattern check at
chat.py:160 matches, the stored summary is fetched with a
1 500-token budget and prepended to the prompt.
Generate — a Claude model when the request names one
and a key is present in X-Anthropic-Key or the
environment; otherwise Ollama, defaulting to
qwen2.5-coder:14b (chat.py:29).
Attribute — every retrieved chunk becomes a
Citation with its snippet truncated to 500 characters;
extract_sources() collapses them to one row per file, keeping the
best-scoring line.
Score the answer — calculate_confidence() mixes
four terms into a single number returned alongside the text. Its arithmetic is
set out in Drawer 03.
Two generation paths, two model tables
The browser client does not always reach POST /chat. When a
Claude model is selected and a key is present,
web/src/lib/api.ts posts to the Next.js route handler at
web/src/app/api/chat/route.ts, which performs its own
POST /search against the backend with a fixed
limit: 5 and calls Anthropic directly — bypassing the router, the
50-chunk budget, the living-summary injection and the confidence calculation.
That handler keeps a second, differently-populated model table from the one in
chat.py:37.