Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 14 additions & 13 deletions server/dashboard/src/modules/search/SearchPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down Expand Up @@ -172,7 +169,11 @@ function Results({
);
}
if (query.trim().length < 2) {
return <Empty title="Type a query">At least two characters, then results appear here.</Empty>;
return (
<Empty title="Type a query">
At least two characters, then press Enter to search.
</Empty>
);
}
switch (mode) {
case 'semantic':
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
21 changes: 15 additions & 6 deletions server/internal/httpapi/workspacesearch.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down