Skip to content

Expose kind and entity filters on knowledge search - #16

Merged
TheGreatAxios merged 6 commits into
mainfrom
cl-5021-corbits-knowledge-engine-search-cannot-filter-by-kind-even
Aug 2, 2026
Merged

Expose kind and entity filters on knowledge search#16
TheGreatAxios merged 6 commits into
mainfrom
cl-5021-corbits-knowledge-engine-search-cannot-filter-by-kind-even

Conversation

@TheGreatAxios

@TheGreatAxios TheGreatAxios commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Summary

  • hybridSearch already implements kinds/entityIds filtering; nothing surfaced them. Threads both through:
    • KnowledgeSearchParams (in-process plane callers) → hybridSearch
    • POST /api/knowledge/search request body (kinds?, entity_ids?) → the plane
  • The filter is applied to BOTH retrieval channels — lexical (fetchLexicalCandidates) and dense (fetchDenseCandidates) — before RRF fusion runs, so every fused hit matches the requested kind/entity. There is no dense-only escape hatch: a document that doesn't match the filter is never a candidate on either leg.
  • An empty array on either field is equivalent to omitting it (no filter), not "match nothing" — including for the "empty query requires a structured filter" check in hybridSearch.
  • No filter supplied = identical behavior to today.
  • Out of scope (separate tickets/PRs): the capture route's missing kind (CL-5011), the authorization asymmetry (CL-5022).

Closes CL-5021

Test plan

  • Route tests (src/routes/routes.test.ts): kinds/entity_ids thread through, absent filters stay unset, empty arrays pass through unchanged (not silently dropped at the route layer), non-string-array kinds rejected with 400
  • Plane test (src/knowledge.test.ts): createKnowledgePlane().search() passes kinds/entityIds straight to hybridSearch
  • Dense-channel filtering (src/services/search.test.ts, fetchDenseCandidates kind/entity filtering): a fake postgres handle evaluates the actual generated SQL/params against a canned two-row dense result set (one row matching the filter, one that would otherwise leak through via pure semantic similarity) — fails if either predicate is dropped from the dense query
  • Could not run bun install / bun test locally (sandboxed, OOM-prone dev machine) — CI is the verification gate for this PR
  • Note: an integration test against a live database exercising the real fused (lexical+dense) path is tracked separately, not part of this PR

hybridSearch already filtered by document kind and entity id; the
plane's search() and the HTTP search route never surfaced those
params, so no in-process or HTTP caller could reach them. Thread
kinds/entityIds through KnowledgeSearchParams and accept kinds/
entity_ids on POST /api/knowledge/search. No filter = unchanged
behavior.
…ers (CL-5021)

Review found the kinds/entityIds wording promised an exact post-fusion
guarantee the implementation doesn't provide: hybridSearch's dense/
semantic channel has no kind or entity predicate, so a fused hit can
surface a document that doesn't match the requested filter. Making
the dense channel filter-aware is a materially larger, untestable-
without-a-live-Postgres change (raw SQL query shape, hnsw/index
implications) than this ticket's scope, so instead every place the
filter is documented now says plainly: these narrow the lexical leg
only, and callers needing an exact guarantee must filter hits
themselves.

Also documents that an empty array on either filter is equivalent to
omitting it (no filter, not "match nothing"), including for the
empty-query-requires-a-structured-filter check in hybridSearch, and
adds a route test pinning that [] passes through unchanged rather
than being silently dropped.
kinds/entityIds only reached fetchLexicalCandidates; fetchDenseCandidates
had no equivalent predicate, so a document that didn't match the caller's
filter could still surface through the dense leg once RRF fusion merged
both channels. Push the same predicate into the dense query (kd.kind =
ANY(...) and a knowledge_edge subquery for entityIds, bound the same way
fetchChunkVectors already binds a text[] param) so every fused hit matches
the requested filter regardless of which channel found it. Updates the
docs/comments that previously called this out as a known limitation.
@TheGreatAxios

Copy link
Copy Markdown
Contributor Author

Pushed a fix for the fusion-bypass gap called out in the "Known limitation" section: kinds/entityIds are now applied to the dense channel's query too (fetchDenseCandidates), not just the lexical one, before RRF fusion runs. The dense channel's SQL already joins knowledge_document, so the kind predicate is a plain kd.kind = ANY($n::text[]); the entity predicate mirrors the lexical channel's knowledge_edge subquery. Both use the same bound-array pattern fetchChunkVectors already uses for chunk_id = ANY($n::text[]), and both keep the existing "empty array == no filter" semantics.

Updated src/services/search.ts, src/knowledge.ts, src/routes/search.ts, PRODUCT.md, and IMPLEMENTATION.md to drop the "lexical-only" caveat now that it no longer applies.

Added fetchDenseCandidates kind/entity filtering in src/services/search.test.ts: a fake postgres handle that evaluates whatever kind/entity predicate is actually present in the generated SQL/params against a two-row canned dense result set (one row matching the filter, one that would previously leak through via pure semantic similarity). If the predicate were dropped from the dense query, the fake falls back to returning both rows unfiltered — exactly like a live Postgres connection with no WHERE clause would — so the non-matching row would appear and the test would fail.

…me (CL-5021)

- Extract ChannelFilterFields, shared by LexicalCandidateParams,
  FetchDenseCandidatesArgs, and HybridSearchArgs instead of redeclaring
  kinds/entityIds three times.
- Document on fuseRrf why filtering each channel independently before
  fusion is safe: RRF only reads each survivor's rank within its own
  channel, never the survivor count or pool fraction, so removing
  non-matching candidates upstream can't distort it. A score-blending
  fusion would not share this property.
- Rename the routes.test.ts empty-array test to match the file's
  short-name-plus-comment convention instead of embedding the rationale
  in the test name itself.
… layers (CL-5021)

KnowledgeSearchParams used the bare ?: T form for k/kinds/entityIds
while services/search.ts's ChannelFilterFields already used ?: T |
undefined. Widen KnowledgeSearchParams to match, since callers spread
optional values that may be explicitly undefined. Also annotate the
hybridSearch test double's return type so mockImplementation call
sites can return a narrower/empty result without conflicting with the
type inferred from the first mock body.
Guards the invariant just documented on fuseRrf: score depends only on a
candidate's own rank within its channel, never how many candidates that
channel started with. Constructs inputs where a percentile/pool-size-aware
blend would rank oppositely, so a future switch away from rank-based fusion
fails this test instead of silently breaking filter safety.
@TheGreatAxios
TheGreatAxios merged commit ee8cf7d into main Aug 2, 2026
1 check passed
@TheGreatAxios
TheGreatAxios deleted the cl-5021-corbits-knowledge-engine-search-cannot-filter-by-kind-even branch August 2, 2026 17:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant