feat(search): explicit modes, match provenance, and strict filtering - #20
Merged
Conversation
Three related changes to the search contract, done together because they touch
the same seam and splitting them would migrate both backends twice.
**mode** — "hybrid" (default), "keyword" or "semantic", on the backend contract
rather than as sugar over semantic_weight. It decides which candidates exist,
not merely how they are weighted, which fixes a real wrong answer: a weight of
0.0 still admitted semantically-matched documents to the candidate set at score
0, so a keyword search returned things that matched no keyword. The UI's
Keyword/Semantic toggle had exactly this bug, since it set the weight.
**match** — every result now says why it came back:
{"type": "keyword"|"semantic"|"both"|"filter"|"none",
"keyword_score": 0-1, "semantic_score": 0-1}
Membership is tracked, not inferred from the scores. Cosine rescaled to [0,1]
is positive for very nearly every embedded document, so "semantic_score > 0"
would label the whole index a semantic match; "semantic" means the document was
among the K nearest, which is what the arm actually retrieved. A listing or a
pure filter reports "none"/"filter" rather than claiming a keyword hit at
score 0.
**filters** — exact-match constraints pushed into the backend query, on every
path, in every mode. Fields must declare `filterable: true`, and filtering on
one that does not raises rather than being ignored.
Both of those are deliberate, and both are about the same failure mode: a filter
may be carrying a tenant or user boundary, and the dangerous outcome is not an
error but a silent one. Filtering only the keyword arm would let a semantic
search return precisely the documents the filter existed to exclude; ignoring an
unknown field would return the whole index. An entity of a doc_type that does
not model the field never matches, which falls out of comparing a missing value.
Also fixed: `filterable` was dropped when a doc_type was serialized back to
YAML, so a field declared filterable came back non-filterable and every filter
on it was then refused as undeclared — a failure arriving long after the cause.
`score` keeps each backend's native ranking value; the normalised, comparable
numbers are in `match`. Phase 2 records all three per returned document.
545 tests pass; storage/base.py and ui/view.py at 100%.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This was referenced Aug 11, 2026
…antic arm These tests passed locally and failed in CI, which is the worst shape a test can have. CI does not install the `semantic` extra — fastembed is a model download — so no embedding provider exists there, and the semantic arm silently falls back to keyword. That fallback is precisely the behaviour these tests exist to tell apart: they assert a keyword search excludes what a semantic one includes, and with both arms collapsed to keyword the assertions are meaningless. Five of them failed on exactly that. FakeEmbedProvider is already in the package for this, with synonym groups so conceptually related phrases land near each other without sharing literal words. It makes the semantic path deterministic and, more importantly, actually present in CI — where the alternative, skipping when no provider is available, would have left the whole feature untested on every run. Verified in a CI-identical environment (no fastembed): 629 pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Three changes to the search contract, landed together because they touch the same seam — splitting them would migrate both backends twice.
mode— hybrid | keyword | semanticOn the backend contract, not sugar over
semantic_weight. It decides which candidates exist, not merely how they are weighted.This fixes a wrong answer rather than adding a knob. A weight of
0.0still admitted semantically-matched documents to the candidate set at score 0 — so a keyword search returned documents that matched no keyword. The explorer's Keyword/Semantic toggle had exactly this bug, because it set the weight.semantic_weightstill blends the two arms within hybrid. Asking for semantic mode now runs the vector arm whatever the configured weight says — a configured0.0silently answering a different question than the one asked was the same class of bug.match— why a document came back{"type": "keyword" | "semantic" | "both" | "filter" | "none", "keyword_score": 0.0-1.0, "semantic_score": 0.0-1.0}Membership is tracked, not inferred from the scores. Cosine rescaled to [0,1] is positive for very nearly every embedded document, so
semantic_score > 0would label the whole index a semantic match. "semantic" means the document was among the K nearest — what the arm actually retrieved.A listing or a pure filter reports
none/filterrather than claiming a keyword hit at score 0. Surfaced in the explorer as a badge, with both arm scores on hover.filters— a hard predicate, not a ranking hintExact-match constraints pushed into the backend query, on every path, in every mode. Fields must declare
filterable: true, and filtering on one that does not raises.Both of those are deliberate, and both target the same failure mode: a filter may be carrying a tenant or user boundary, and the dangerous outcome is not an error but a silent one.
Two bugs found while building this
The filter matched nothing at first. I had written the JSON path as
$.fields.<name>; schema fields are stored flat, so it silently matched zero rows — a fail-open-shaped mistake that happened to fail closed. Caught by testing against real data rather than trusting the shape.filterablewas dropped when a doc_type was serialised back to YAML. A field declared filterable came back non-filterable, and every filter on it was then refused as undeclared — a failure arriving long after its cause. Now covered by a round-trip test.Notes
scorekeeps each backend's native ranking value; the normalised, comparable numbers live inmatch. An existing test deliberately pins the keyword scale as an exact ratio, and normalising then rounding breaks it — so the comparable numbers went where they do no harm. The retrieval audit in the next phase records all three.OpenSearch already ran the two arms as separate queries and kept both score maps, so provenance there was mostly a matter of reading membership off the dicts it already had. Both backends move together; the mirrored candidate-set contract between them is preserved.
545 tests pass.
storage/base.pyandui/view.pyat 100%. New coverage for mode membership, the K-bound on semantic matching, filter enforcement in all three modes, fail-closed validation, and the YAML round trip.🤖 Generated with Claude Code