/** * Hybrid search orchestrator: resolve mode, run retrievers in parallel, fuse * by rank, expand within budget, trace everything * (docs/hybrid-retrieval-design.md). * * Single-retriever modes flow through the same pipeline — rrfFuse over one * list preserves its order — so lexical, semantic, and hybrid all produce the * same result shape and the same trace record. * * `retrieveCandidates` is the candidate-level core (also used by the eval * harness, which needs forced modes, a configurable `k`, and no trace * pollution); `runSearch` wraps it with span expansion and tracing for the * tool. */ import type { EmbsearchService } from "../embsearch/embsearch-service.js"; import type { FusedCandidate, ResolvedSearchMode, SearchMode, SearchTrace } from "./types.js"; export interface RetrieveOptions { cwd: string; query: string; mode?: SearchMode; /** Optional glob filter applied to file paths. */ glob?: string; /** Maximum fused candidates returned. */ limit?: number; /** RRF constant override (eval harness sweeps this). Default: {@link DEFAULT_RRF_K}. */ rrfK?: number; /** Rerank the fused top-50 before slicing to `limit`. Default: true. */ rerank?: boolean; /** * Ask the daemon to fuse its own BM25 index with the vectors and return one * already-fused ranking, instead of taking a dense-only list. Needs a store * built with `--hybrid`. The fused list arrives as a single "embed" leg, * because a pre-fused ranking has no per-retriever structure left to record. * * Prefer {@link bm25Leg}: fusing here keeps the legs separable in the trace * and lets the grep leg participate. */ daemonHybrid?: boolean; /** * Fetch the daemon's BM25 index as its own ranked list and fuse it here, * alongside dense and grep. * * Defaults to on wherever the daemon can serve it, because BM25 is the * better lexical leg on the indexed corpus: Recall@50 0.790 -> 0.879, 6 of * 62 queries better and 0 worse (p <= 0.05). When it is on, the grep leg * narrows to files the index has not read — see `staleFiles`. * * Set `false` to force ripgrep as the only lexical leg; the eval harness * does this to keep measuring what the old rows measured. */ bm25Leg?: boolean; /** * Reorder the fused shortlist with the daemon's cross-encoder instead of * the deterministic reranker. Needs embsearch >= 0.3.0; costs one model * pass per scored candidate. */ crossEncoder?: boolean; service?: EmbsearchService; signal?: AbortSignal; } export interface RetrieveResult { candidates: FusedCandidate[]; resolvedMode: ResolvedSearchMode; degradedReason?: string; indexPhase: SearchTrace["indexPhase"]; retrievers: SearchTrace["retrievers"]; /** Set while the embedding index is still building. */ indexing?: { done: number; total: number; }; rrfK: number; rerank?: SearchTrace["rerank"]; } export interface RunSearchOptions extends RetrieveOptions { /** Approximate token budget for the result text. */ tokenBudget?: number; } export interface RunSearchResult { text: string; resolvedMode: ResolvedSearchMode; degradedReason?: string; resultCount: number; /** Set while the embedding index is still building. */ indexing?: { done: number; total: number; }; } /** * Move candidates from files the index has not read to the front. * * They are there because grep found them and nothing else could: the index is * ranking a stale copy of the file, or has never seen it. Left to fuse, they * lose — RRF rewards agreement, and one leg reporting a single document is * outvoted by two legs agreeing on hundreds. Measured: scoping grep to stale * files without this hoist scored 25% on the live-edit set where unscoped grep * scored 100%, because the fused window filled with consensus hits about the * indexed copy. * * Capped, because "stale" scales with how far behind the index is. A few * edited files is the case this exists for; a fresh checkout makes everything * stale, and hoisting all of it would quietly turn hybrid search back into * grep. Past the cap the remainder keeps its fused position. */ export declare function hoistStaleCandidates(candidates: readonly FusedCandidate[], staleFiles: ReadonlySet): FusedCandidate[]; /** * Fold overlapping or adjacent spans of the same file into their best-ranked * occurrence. * * Chunks overlap by design (see the chunker's `CHUNK_OVERLAP_LINES`) and each * one is a separate id, so neighbouring chunks of one region survive fusion as * separate candidates and take separate result slots — showing the model code * it already has. Measured on the 62-query set at the tool's default * `limit=5`, 43 queries had such a pair in their top 5 and 58 of 310 slots * went to repeated code. * * Merging is free recall: the union of two overlapping spans matches exactly * what either matched, so nothing is gained by widening — the gain is entirely * the slot handed back to the ranked tail. Recall@5 0.597 -> 0.677, Recall@1 * unchanged (merging cannot alter the top result). * * Adjacency counts as overlap (`endLine + 1`): two chunks that abut describe * one continuous region, and rendering them as separate results implies a gap * that is not there. */ export declare function mergeOverlappingSpans(candidates: readonly FusedCandidate[]): FusedCandidate[]; export declare function retrieveCandidates(options: RetrieveOptions): Promise; export declare function runSearch(options: RunSearchOptions): Promise; //# sourceMappingURL=hybrid-search.d.ts.map