Drawer 02 · Query lifecycle

From call slip to shelf

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.

compute_service/services/zoekt.py : 132-156ZoektService.search
def search(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

Lifecycle of a search query through the hybrid retrieval service Two entry points at the top, POST /search and POST /chat, converge on HybridSearchService.search. The service splits into two parallel legs. The keyword leg expands the query into up to five variants, takes the first three, and calls Zoekt or ripgrep once per variant with twice the requested limit, then de-duplicates by file and line. The semantic leg embeds the query through a cache and searches FAISS with five times the limit when a repository filter is present. The two ranked lists meet at a Reciprocal Rank Fusion block, are filtered by minimum score, passed to a reranker, and truncated. A side panel marks that the chat entry point supplies no project path, so the keyword leg yields an empty list, and that Zoekt text output carries no score, so every keyword hit is assigned a score of one. query lifecycle — hybrid_search.py:89-181 POST /search CrossRepoSearch → per ready repo project_path = repo.local_path POST /chat search_codebase() → module global project_path = None search_type comes from the request body on /search and from QueryRouter on /chat. "hybrid_rerank" is mapped down to "hybrid" at chat.py:217. HybridSearchService.search(query, project_path, repo_ids, limit, min_score, search_type) keyword leg — weight 0.4 QueryExpander.expand(query) 25-entry synonym table + camelCase ⇄ snake_case, capped at 5 variants for exp_query in expanded[:3] three separate subprocess calls, each asking for limit × 2 rows zoekt -index_dir … 30 s timeout, text output rg --json --max-count fallback when zoekt absent de-duplicate on f"{file}:{line}", preserving first occurrence → List[SearchResult], every score = 1.0 semantic leg — weight 0.6 embedding_service.embed_query(query) SHA-256 key → LRU + SQLite cache, 7-day TTL, single vector faiss.normalize_L2(query) → index.search(query, k) k = limit × 5 when repo_filter is set, otherwise limit × 2 IndexFlatIP exact inner product HNSW_THRESHOLD = 50000 declared, never applied post-filter: drop rows whose repo_id is not in repo_filter → List[VectorDocument] with cosine score in 0…1 _reciprocal_rank_fusion(keyword_results, semantic_results) merge on the same f"{file}:{line}" key · Σ weight / (60 + rank) · normalise by the maximum · sort descending filter score ≥ min_score min_score defaults to 0.0 _rerank_results(query, results, limit) skipped when fewer than two rows survive results[:limit] final truncation SearchResponse results[] = {repo, file, line, score, snippet, match_type} query · total · searched_repos[] models/schemas.py:46 ChatResponse response · citations[] · sources[] · confidence · chunks_found search_mode · search_metadata{query_type, reasoning, …} routes/chat.py:97 consequences worth recording on the slip A · Zoekt's text output carries no relevance figure, so _parse_zoekt_text_output assigns score = 1.0 to every row (zoekt.py:236). Keyword ordering therefore contributes through rank position only; keyword_score is constant and carries no signal. B · /search runs the whole pipeline once per repository, then merges. Fusion is per repository; the cross-repository merge is a plain sort. Scores from different repositories are compared directly even though each was normalised against its own maximum.
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.

Decision tree of the query router A query enters at the left and is scored against three regular-expression groups: twelve symbol patterns, twelve concept patterns and fourteen architecture patterns. Each group returns the maximum weight of any pattern that matched. Two boosts may raise the symbol score: up to zero point three for symbol indicator words, and zero point three more for short queries containing camel case or snake case. The highest of the three scores selects the query type; a maximum below zero point three falls back to concept at fixed confidence zero point five. Five outcome boxes on the right give the resulting search mode, whether reranking is enabled, and the limit multiplier applied to the base limit of fifty. query_router.py:142 — route(query) → RoutingDecision query.lower() .strip() SYMBOL_PATTERNS — 12 ^find · ^where is · definition of · `ident` CONCEPT_PATTERNS — 12 ^how does · ^why · ^explain · purpose of ARCHITECTURE_PATTERNS — 14 architect · data flow · pipeline · workflow boosts on symbol score +0.2 one indicator word · +0.3 two or more +0.3 if ≤3 words and camelCase or snake_case argmax of three confidence = that score capped at 1.0 if confidence < 0.3 → CONCEPT at 0.5 _select_search_mode(query_type, confidence) — query_router.py:247 SYMBOL, confidence ≥ 0.8 → KEYWORD_ONLY · rerank off · limit × 0.5 → 25 results requested SYMBOL, confidence < 0.8 → HYBRID · rerank on (default) · limit × 1.0 → 50 ARCHITECTURE, any confidence → HYBRID_RERANK · rerank forced on · limit × 2.0 → 100 CONCEPT, confidence ≥ 0.7 → HYBRID_RERANK · rerank on · limit × 1.0 → 50 CONCEPT, confidence < 0.7 → HYBRID · rerank on · limit × 1.0 notes · base_limit is hard-coded to 50 at chat.py:508. · HYBRID_RERANK is collapsed to "hybrid" before it reaches HybridSearchService, which reranks whenever use_reranking is true — so the distinction never changes behaviour. · KEYWORD_ONLY reaches a Zoekt call with project_path=None and returns an empty list. The fast path returns nothing. · rerank_enabled is reported to the client but is not passed down; the service-level flag decides. worked example — "How does the reranker combine scores?" symbol = 0.00 · concept = 0.90 (^how\s+(does|do|is|are|can|should)) · architecture = 0.00 → CONCEPT at 0.90 ≥ 0.70 → HYBRID_RERANK, rerank on, ×1.0 → 50 chunks requested, semantic leg only.
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

  1. RouteQueryRouter.route() yields the mode and the limit multiplier.
  2. Retrievesearch_codebase() requests 50 × multiplier chunks.
  3. 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.
  4. 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).
  5. 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.
  6. Score the answercalculate_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.