GrooveSeek

Semantic search over a Markdown knowledge base, served over MCP.

View the Project on GitHub alphabet-h/grooveseek

11. Exclude a term from both halves of the hybrid search

Context and Problem Statement

ADR-0002 made the full-text half of the hybrid compile a query into per-token OR phrases, which widened recall but left no way to narrow a search by a word the caller wants excluded. path_globs and tags_any / tags_all filter on document metadata only; nothing in the query language reaches the text of a chunk. SQLite FTS5 has a NOT operator, but the vector half of the hybrid knows nothing about words — a full-text-only exclusion would be undone by Reciprocal Rank Fusion re-admitting the same chunk from the vector leg, so a search that reported an exclusion would not actually have enforced it.

The question this decision answers: given a hybrid of two retrievers where only one understands text, how does excluding a term reach both, and what happens to a query that excludes everything and leaves nothing to search for?

Decision Drivers

Considered Options

  1. NOT on the full-text half only. Compile the negative phrases into the FTS5 expression and leave the vector half untouched.
  2. A hard filter on both halves. The FTS5 expression carries (positives) NOT (negatives), and the vector half drops any candidate whose chunk id is in the set FTS5 returns for the negative expression alone.
  3. Soft demotion. Lower the rank of a chunk that contains an excluded term after fusion, rather than dropping it.

Decision Outcome

Chosen option: 2 — a hard filter on both halves, judged by one FTS5 evaluation shared by both legs.

Option 1 rebuilds the ADR-0002 defect in mirror image: a hybrid search that reports an exclusion and does not enforce it on the leg fusion also draws from. Option 3 is a contract nobody can state to a caller — “usually gone” is not an answer to “did my exclusion work”. Option 2 costs one rowid-only scan of the negative expression per search, with no ranking and no LIMIT: measured at 934.5µs (best of 5) for a negative expression matching every one of 5,000 chunks — the worst case, an excluded term present in every row — against 3.5855ms for the ranked FTS query it accompanies in the same search (measured: cargo test -p grooveseek --release --lib the_exclusion_id_scan_stays_cheaper_than_the_ranked_fts_query -- --ignored --nocapture). The scan stays at just over a quarter of the cost of the query it rides alongside.

Interface changes

Consequences

Confirmation

More Information