diff --git a/server/dashboard/src/modules/search/SearchPage.tsx b/server/dashboard/src/modules/search/SearchPage.tsx index 2c9cd04..f61d8ca 100644 --- a/server/dashboard/src/modules/search/SearchPage.tsx +++ b/server/dashboard/src/modules/search/SearchPage.tsx @@ -44,18 +44,15 @@ export default function SearchPage() { const queryParam = params.get('q') ?? ''; const [draft, setDraft] = useState(queryParam); - // Debounce input → URL after 250ms idle; Enter commits immediately. - useEffect(() => { - const id = setTimeout(() => { - if (draft === queryParam) return; - const next = new URLSearchParams(params); - if (draft.trim()) next.set('q', draft); - else next.delete('q'); - setParams(next, { replace: true }); - }, 250); - return () => clearTimeout(id); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [draft]); + // Typing changes `draft` and nothing else. The query in the URL — which is + // what actually runs a search — moves only on submit. + // + // This used to debounce draft into the URL after 250ms idle. That is the + // usual pattern and it is wrong here: a semantic search embeds the query + // through the configured provider, so every pause while typing spent a real + // API call and a full fan-out to answer a half-written question. "retry with + // exponential backoff" typed at a normal pace fires on "retry", "retry with", + // "retry with expo" — three searches nobody asked for and one they did. // Follow the URL when it changes from outside (a pasted link, back button). useEffect(() => { @@ -172,7 +169,11 @@ function Results({ ); } if (query.trim().length < 2) { - return At least two characters, then results appear here.; + return ( + + At least two characters, then press Enter to search. + + ); } switch (mode) { case 'semantic': diff --git a/server/dashboard/src/modules/search/components/SearchBar.tsx b/server/dashboard/src/modules/search/components/SearchBar.tsx index d6570bf..92977e3 100644 --- a/server/dashboard/src/modules/search/components/SearchBar.tsx +++ b/server/dashboard/src/modules/search/components/SearchBar.tsx @@ -13,7 +13,7 @@ export function SearchBar({ }: { value: string; onChange: (v: string) => void; - /** Fired on Enter — bypasses the debounce and commits immediately. */ + /** Fired on Enter. This is the ONLY thing that runs a search — typing does not. */ onSubmit?: (v: string) => void; placeholder?: string; className?: string; diff --git a/server/internal/httpapi/workspacesearch.go b/server/internal/httpapi/workspacesearch.go index e3ca4d3..757d0ba 100644 --- a/server/internal/httpapi/workspacesearch.go +++ b/server/internal/httpapi/workspacesearch.go @@ -96,8 +96,10 @@ type workspaceSearchStaleFTSRepoPayload struct { } // projectHits is the per-project intermediate state accumulated across -// the parallel fan-out. Dense and BM25 sides arrive separately and are -// fused inside the goroutine before being collected. +// the parallel fan-out. Dense and BM25 sides arrive separately and are fused +// AFTER the fan-out, in the serial loop below g.Wait() — fusion needs both +// sides, so it cannot live in either goroutine. See the comment above that +// loop for why parallelising it buys nothing. type projectHits struct { ProjectPath string // FusedChunks are the per-project chunks ranked by RRF over the @@ -110,6 +112,13 @@ type projectHits struct { // (positive, unbounded — SQLite's bm25() flipped via -bm25 at // the chunksfts boundary). Normalized into candidacy via // per-query min-max before being blended. + // + // Computed on the RAW BM25 list, not on FusedChunks beside it. The two + // fields are scored at different layers: the chunk list a caller sees has + // been through RRF, where the dense side gets an equal vote, while the + // projects panel ranks on this number alone. So anything that moves BM25 + // moves the panel directly and the chunk list only after fusion has had a + // say — worth knowing before reading a panel reorder as a ranking change. BM25Signal float32 // Candidacy is the α-blended, per-query-normalized score the // projects panel ranks by; recomputed after every project's @@ -119,10 +128,10 @@ type projectHits struct { // WorkspaceSearch — GET /api/v1/workspaces/{id}/search. // -// Hybrid BM25+dense fan-out. Each project runs two queries in -// parallel: dense (vector-store cosine) and sparse (SQLite FTS5 BM25 over -// chunks_fts). Per project, the two ranked lists are fused via -// Reciprocal Rank Fusion. Across projects, an α-blended candidacy +// Hybrid BM25+dense fan-out. Dense (vector-store cosine) runs once per +// project, concurrently; sparse is ONE FTS5 BM25 query over chunks_fts for the +// whole workspace, partitioned per project by the caller. Per project, the two +// ranked lists are fused via Reciprocal Rank Fusion. Across projects, an α-blended candidacy // score (with per-query min-max normalization on both signals) plus // a relative threshold (`candidacy ≥ best × 0.4`) keeps the result // set focused on repos that actually share vocabulary or semantics