diff --git a/IMPLEMENTATION.md b/IMPLEMENTATION.md index 201173d..c5e4cde 100644 --- a/IMPLEMENTATION.md +++ b/IMPLEMENTATION.md @@ -477,7 +477,7 @@ Each route is guarded with `grantGuard(deps, action)`, which applies the host's | Method + path | Grant action | Request body | Response | |---|---|---|---| | `POST /api/knowledge/capture` | `capture` | `{ title, text, acl? }` | `200 { status: "captured" }`; `400` on validation | -| `POST /api/knowledge/search` | `search` | `{ query, k? }` (k 1–50) | `200 SearchResponse` (`{ hits[], evidence, degraded? }`); `400` on bad input | +| `POST /api/knowledge/search` | `search` | `{ query, k?, kinds?, entity_ids? }` (k 1–50; `kinds`/`entity_ids` narrow every retrieval channel — lexical and dense — to a document `kind` or linked entity id before fusion; unset or `[]` = unfiltered) | `200 SearchResponse` (`{ hits[], evidence, degraded? }`); `400` on bad input | | `GET /api/knowledge/timeline` | `search` | — | `200 { events: [{ at, title, source, tenantId, principalId }] }` — durable recent documents for the caller's scope (`last_seen_at` DESC), filtered with the same visibility SQL + `acl_block` post-filter as search. One event per document (active live version), not per capture attempt. See wire field notes below. | `mountKnowledgeRoutes` and `mountKnowledgeEngine` both mount the three HTTP routes. MCP is a separate package (`@corbitsdev/hono-openapi-mcp`). diff --git a/PRODUCT.md b/PRODUCT.md index c7b753b..a24643b 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -75,13 +75,18 @@ for a user must check the capability themselves (see README). ```http POST /api/knowledge/capture { "title", "text", "acl?" } -POST /api/knowledge/search { "query", "k?" } +POST /api/knowledge/search { "query", "k?", "kinds?", "entity_ids?" } GET /api/knowledge/timeline ``` Requests are authenticated upstream by Interchange (however the host chose); the SDK routes assume a resolved principal on the context. +`kinds`/`entity_ids` narrow both the lexical and dense/semantic legs of +search before results are fused, so every hit matches the requested +kind/entity. An empty array on either field is equivalent to omitting it (no +filter), not "match nothing". + ### Document ACL (who may surface on search/timeline) — set at capture Optional on capture; default is scope-wide (the company brain), or diff --git a/src/core/hybrid-search.test.ts b/src/core/hybrid-search.test.ts index 3b80c0d..7637dac 100644 --- a/src/core/hybrid-search.test.ts +++ b/src/core/hybrid-search.test.ts @@ -61,6 +61,42 @@ describe("fuseRrf", () => { test("empty channels produce no candidates", () => { expect(fuseRrf([[], []])).toEqual([]); }); + + // This test protects the invariant documented on fuseRrf above: fusion + // reads a candidate's own rank within its channel and nothing else — not + // how many candidates that channel started with or how many survived. If + // fuseRrf is ever rewritten to blend on something pool-size- or + // fraction-aware (e.g. normalizing rank to a percentile of the channel), + // this test must fail, because that is precisely the property being + // protected. + test("fuses by raw rank only, never by the size of the pool a candidate survived from", () => { + // "Y" is the worst survivor of a channel heavily thinned by filtering + // (rank 2 of ~2 candidates). "X" is a solid-but-unremarkable finisher in + // a channel filtering barely touched (rank 10 of ~100 candidates). This + // is exactly the shape independent per-channel filtering produces. + // + // Hand-computed RRF (rrfK=60), which reads only the rank number: + // score(Y) = 1/(60+2) = 1/62 ≈ 0.0161290 + // score(X) = 1/(60+10) = 1/70 ≈ 0.0142857 + // Y's raw rank (2) beats X's raw rank (10), so rank-based fusion ranks + // Y above X. + // + // A score-blending fusion that normalized each rank to a percentile + // within its own channel ((total - rank + 1) / total) would compute the + // OPPOSITE order: + // percentile(Y) = (2 - 2 + 1) / 2 = 0.50 (bottom half of a tiny pool) + // percentile(X) = (100 - 10 + 1) / 100 = 0.91 (top decile of a large pool) + // ranking X above Y. A change to that kind of blending flips this + // assertion and fails the test. + const heavilyFilteredChannel = [{ chunkId: "Y", rank: 2 }]; + const mostlyIntactChannel = [{ chunkId: "X", rank: 10 }]; + + const fused = fuseRrf([heavilyFilteredChannel, mostlyIntactChannel]); + + expect(fused.map((f) => f.chunkId)).toEqual(["Y", "X"]); + expect(fused[0]?.score).toBeCloseTo(1 / 62, 10); + expect(fused[1]?.score).toBeCloseTo(1 / 70, 10); + }); }); describe("isBatchQueriesWithinBound", () => { diff --git a/src/core/hybrid-search.ts b/src/core/hybrid-search.ts index 7966093..57ff01c 100644 --- a/src/core/hybrid-search.ts +++ b/src/core/hybrid-search.ts @@ -50,6 +50,17 @@ export function toRankedCandidates( // `embeddings-rerank.md` §3's explicit rule). Returns candidates sorted // descending by fused score; ties are left in fusion-encounter order // (callers needing a further tiebreak, e.g. recency, apply it themselves). +// +// This is exactly why filtering each channel's candidates independently +// BEFORE they reach this function (kinds/entityIds in services/search.ts) +// never distorts relevance: a chunk's score here depends only on its OWN +// rank within each channel's surviving list, never on how many candidates +// survived or what fraction of the original pool they represent. Removing +// non-matching candidates upstream renumbers ranks but preserves each +// survivor's relative order, which is all RRF ever reads. A score-blending +// fusion would NOT have this property — removing candidates would shift +// score distributions (min/max, density) that a blend depends on — so this +// safety does not generalize past rank-based fusion. export function fuseRrf( channels: readonly (readonly RankedCandidate[])[], rrfK: number = RRF_K_DEFAULT, diff --git a/src/knowledge.test.ts b/src/knowledge.test.ts index 6bc4ba1..c53c8cb 100644 --- a/src/knowledge.test.ts +++ b/src/knowledge.test.ts @@ -30,6 +30,7 @@ import { import type { KnowledgeConfig } from "./mount-config.ts"; import * as realDb from "./db/client.ts"; import * as realSearch from "./services/search.ts"; +import type { HybridSearchResult } from "./services/search.ts"; const PRINCIPAL = "p1"; const TENANT = "t1"; @@ -170,10 +171,10 @@ describe("createKnowledgePlane — construction validation", () => { }); describe("createKnowledgePlane.search — ACL post-filter wiring", () => { - const hybridSearch = mock(() => + const hybridSearch = mock((): Promise => Promise.resolve({ hits: [hit("d-blocked"), hit("d-open")], - evidence: "strong" as const, + evidence: "strong", }), ); @@ -255,6 +256,39 @@ describe("createKnowledgePlane.search — ACL post-filter wiring", () => { await plane.close(); }); + it("threads kinds and entityIds through to hybridSearch", async () => { + // Regression guard for CL-5021: the plane must pass kinds/entityIds + // straight through to the service that already supports them, not + // silently drop them. + hybridSearch.mockClear(); + hybridSearch.mockImplementation(() => + Promise.resolve({ hits: [], evidence: "none" as const }), + ); + sql.mockClear(); + + const { createKnowledgePlane: makePlane } = await import( + `./knowledge.ts?wiring-kinds=${Date.now()}` + ); + const plane = makePlane(wiringConfig); + await plane.search({ + tenantId: TENANT, + principalId: PRINCIPAL, + query: "q", + kinds: ["artifact", "task"], + entityIds: ["e1"], + }); + + expect(hybridSearch).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + kinds: ["artifact", "task"], + entityIds: ["e1"], + }), + ); + + await plane.close(); + }); + it("withholds a hit whose acl_block is unreadable (fail-closed wiring)", async () => { // Non-string/non-array acl_block is the case this PR closed: the post-filter // must remove the hit, not pass it through. diff --git a/src/knowledge.ts b/src/knowledge.ts index 4ca32d3..c6f69c3 100644 --- a/src/knowledge.ts +++ b/src/knowledge.ts @@ -58,7 +58,19 @@ export type KnowledgeIdentity = { export type KnowledgeSearchParams = KnowledgeIdentity & { query: string; - k?: number; + k?: number | undefined; + /** + * Narrows every retrieval channel to documents whose `kind` is one of + * these — see `hybridSearch` in services/search.ts. Applied before fusion, + * so a fused hit is always guaranteed to match. Unset or an empty array + * both mean "no filter" (equivalent, not "match nothing"). + */ + kinds?: string[] | undefined; + /** + * Same scoping as `kinds`, restricted to documents linked to one of these + * entity ids. Unset or an empty array both mean "no filter". + */ + entityIds?: string[] | undefined; }; export type KnowledgeAskParams = KnowledgeIdentity & { @@ -279,6 +291,8 @@ export function createKnowledgePlane( principalId: params.principalId, query: params.query, k: params.k, + kinds: params.kinds, + entityIds: params.entityIds, }); // Block-list post-filter: docs may store acl_block as a list of diff --git a/src/routes/routes.test.ts b/src/routes/routes.test.ts index 71d4d33..5fdd350 100644 --- a/src/routes/routes.test.ts +++ b/src/routes/routes.test.ts @@ -39,9 +39,15 @@ function stubPlane(opts?: { }) { const captured: { title: string; tenantId: string; principalId: string }[] = []; + const searched: Array< + Pick[0], "kinds" | "entityIds" | "k"> + > = []; const catalog = opts?.timelineCatalog ?? []; const plane: KnowledgePlane = { - search: async () => ({ hits: [], evidence: "none" }), + search: async (p) => { + searched.push({ kinds: p.kinds, entityIds: p.entityIds, k: p.k }); + return { hits: [], evidence: "none" }; + }, ask: async () => ({ text: "", citations: [], evidence: "none" }), capture: async (p) => { captured.push({ @@ -61,7 +67,7 @@ function stubPlane(opts?: { }, close: async () => {}, }; - return { plane, captured }; + return { plane, captured, searched }; } function buildApp( @@ -73,7 +79,7 @@ function buildApp( principalId?: string; }, ) { - const { plane, captured } = stubPlane(opts); + const { plane, captured, searched } = stubPlane(opts); const grantConfig = { grantStore: createInMemoryGrantStore(grants), conditionRegistry: {}, @@ -111,7 +117,7 @@ function buildApp( await next(); }); mountKnowledgeRoutes(app, deps); - return { app, captured }; + return { app, captured, searched }; } // Mirrors a host that mounts the knowledge routes outside the tenant prefix @@ -224,6 +230,55 @@ describe("knowledge HTTP routes", () => { expect(res.status).toBe(400); }); + test("search threads kinds and entity_ids through to the plane", async () => { + const { app, searched } = buildApp([grant(PRINCIPAL, "search")]); + const res = await app.request( + "/api/knowledge/search", + jsonPost({ + query: "hello", + kinds: ["artifact", "task"], + entity_ids: ["e1", "e2"], + }), + ); + expect(res.status).toBe(200); + expect(searched).toEqual([ + { kinds: ["artifact", "task"], entityIds: ["e1", "e2"], k: undefined }, + ]); + }); + + test("search with no kinds/entity_ids leaves them unset on the plane call", async () => { + const { app, searched } = buildApp([grant(PRINCIPAL, "search")]); + const res = await app.request( + "/api/knowledge/search", + jsonPost({ query: "hello" }), + ); + expect(res.status).toBe(200); + expect(searched).toEqual([ + { kinds: undefined, entityIds: undefined, k: undefined }, + ]); + }); + + test("search rejects a non-string-array kinds (400)", async () => { + const { app } = buildApp([grant(PRINCIPAL, "search")]); + const res = await app.request( + "/api/knowledge/search", + jsonPost({ query: "hi", kinds: [1, 2] }), + ); + expect(res.status).toBe(400); + }); + + // The route does not collapse [] to absent — hybridSearch treats an empty + // array and an absent field the same way (see services/search.ts). + test("search passes an empty kinds/entity_ids array through unchanged", async () => { + const { app, searched } = buildApp([grant(PRINCIPAL, "search")]); + const res = await app.request( + "/api/knowledge/search", + jsonPost({ query: "hello", kinds: [], entity_ids: [] }), + ); + expect(res.status).toBe(200); + expect(searched).toEqual([{ kinds: [], entityIds: [], k: undefined }]); + }); + test("timeline requires the search grant", async () => { const { app } = buildApp([grant(PRINCIPAL, "capture")]); const res = await app.request("/api/knowledge/timeline"); diff --git a/src/routes/search.ts b/src/routes/search.ts index d1ef807..f9b679e 100644 --- a/src/routes/search.ts +++ b/src/routes/search.ts @@ -9,9 +9,18 @@ import { SearchResponseSchema } from "../core/schemas/search.ts"; import type { RouteDeps } from "./deps.ts"; import { caller, grantGuard, requirePrincipal } from "./deps.ts"; +// `kinds`/`entity_ids` scope every retrieval channel — see the +// `kinds`/`entityIds` doc comments on KnowledgeSearchParams (knowledge.ts) +// for the full explanation. +// +// An empty array on either field is equivalent to omitting it — "no filter" +// — not "match nothing", and does not satisfy the requirement that an empty +// `query` be paired with a non-empty structured filter. const SearchRequest = type({ query: "string >= 1", "k?": "1 <= number.integer <= 50", + "kinds?": "string[]", + "entity_ids?": "string[]", }); export function mountSearchRoute(app: Hono, deps: RouteDeps): void { @@ -20,6 +29,10 @@ export function mountSearchRoute(app: Hono, deps: RouteDeps): void { describeRoute({ tags: ["knowledge"], summary: "Hybrid semantic + keyword search", + description: + "`kinds`/`entity_ids` scope every retrieval channel (lexical and " + + "dense) before results are fused, so every hit matches the " + + "requested kind/entity.", responses: { 200: { description: "Ranked hits with evidence", @@ -36,7 +49,7 @@ export function mountSearchRoute(app: Hono, deps: RouteDeps): void { grantGuard(deps, "search"), validator("json", SearchRequest), async (c) => { - const { query, k } = c.req.valid("json"); + const { query, k, kinds, entity_ids } = c.req.valid("json"); const { scopeId, subjectId } = caller(c); try { const result = await deps.knowledge.search({ @@ -44,6 +57,8 @@ export function mountSearchRoute(app: Hono, deps: RouteDeps): void { tenantId: scopeId, principalId: subjectId, ...(k !== undefined ? { k } : {}), + ...(kinds !== undefined ? { kinds } : {}), + ...(entity_ids !== undefined ? { entityIds: entity_ids } : {}), }); return c.json(result); } catch (err) { diff --git a/src/services/search.test.ts b/src/services/search.test.ts index 54fb27d..97384c5 100644 --- a/src/services/search.test.ts +++ b/src/services/search.test.ts @@ -355,3 +355,174 @@ describe("fetchDenseCandidates hnsw tuning", () => { await expect(fetchDenseCandidates(args(fake.rawSql))).rejects.toThrow("connection reset"); }); }); + +// Regression coverage for the fusion-bypass bug: kinds/entityIds used to be +// applied only to fetchLexicalCandidates, so a document that didn't match +// the caller's filter could still reach the caller through the dense +// channel once RRF fusion merged both result sets. This exercises +// fetchDenseCandidates directly with a fake postgres handle that behaves +// like a real one WOULD for the query fetchDenseCandidates builds: it reads +// the actual SQL text and bound params off the call and only returns rows +// that satisfy whatever kind/entity predicate is (or isn't) present. If the +// implementation stopped sending the predicate to the dense query, this +// fake would fall back to returning every row — unfiltered, exactly like a +// live Postgres would with no WHERE clause — and the assertions below +// would fail. +describe("fetchDenseCandidates kind/entity filtering", () => { + const MODEL_ROW = { model_key: "bbbbbbbbbbbbbbbb", model_id: "m", dims: 768 }; + + // Two chunks the ANN scan would surface on pure semantic similarity: one + // belongs to a document of kind "task" linked to entity "e-match", the + // other to kind "note" linked to no requested entity. A caller filtering + // by kinds: ["task"] or entityIds: ["e-match"] must never see "chunk-note". + const DENSE_ROWS: Array> = [ + { + chunk_id: "chunk-task", + document_id: "doc-task", + version_id: "ver-task", + version: 1, + status: "active", + title: "Task doc", + kind: "task", + adapter: "artifact", + external_ref: "artifact:task", + created_by_kind: "human", + generator_agent_id: null, + snippet_text: "matches on kind and entity", + occurred_at: new Date("2026-01-01T00:00:00Z").toISOString(), + authority: 0.5, + }, + { + chunk_id: "chunk-note", + document_id: "doc-note", + version_id: "ver-note", + version: 1, + status: "active", + title: "Note doc", + kind: "note", + adapter: "artifact", + external_ref: "artifact:note", + created_by_kind: "human", + generator_agent_id: null, + snippet_text: "surfaced purely by semantic similarity", + occurred_at: new Date("2026-01-01T00:00:00Z").toISOString(), + authority: 0.5, + }, + ]; + + // doc-task is linked to entity "e-match"; doc-note is linked to nothing. + const ENTITY_LINKS: Record = { + "doc-task": ["e-match"], + "doc-note": [], + }; + + function openaiEmbedFetch(): typeof fetch { + return (() => + Promise.resolve( + new Response(JSON.stringify({ data: [{ embedding: [0.1, 0.2] }] }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + )) as unknown as typeof fetch; + } + + // Behaves like a real Postgres connection would for exactly the queries + // fetchDenseCandidates issues: model-registry lookup, then the dense + // SELECT itself, evaluating whatever kind/entity predicate the SQL text + // actually contains against the canned dataset above. + function fakeRawSql() { + type FakeTx = { + unsafe: (sqlText: string, params?: unknown[]) => Promise; + savepoint: (fn: (sp: FakeTx) => Promise) => Promise; + }; + function evaluate(sqlText: string, params: unknown[]): unknown[] { + let rows = DENSE_ROWS; + const kindMatch = sqlText.match(/kd\.kind = ANY\(\$(\d+)/); + if (kindMatch) { + const kinds = params[Number(kindMatch[1]) - 1] as string[]; + rows = rows.filter((r) => kinds.includes(r["kind"] as string)); + } + const entityMatch = sqlText.match(/ke\.to_ref = ANY\(\$(\d+)/); + if (entityMatch) { + const entityIds = params[Number(entityMatch[1]) - 1] as string[]; + rows = rows.filter((r) => + (ENTITY_LINKS[r["document_id"] as string] ?? []).some((e) => + entityIds.includes(e), + ), + ); + } + return rows; + } + const tx: FakeTx = { + unsafe: (sqlText: string, params: unknown[] = []) => { + if (sqlText.includes("ORDER BY")) { + return Promise.resolve(evaluate(sqlText, params)); + } + return Promise.resolve([]); + }, + savepoint: (fn: (sp: FakeTx) => Promise) => fn(tx), + }; + const rawSql = { + unsafe: (sqlText: string) => + Promise.resolve( + sqlText.includes("FROM knowledge_embed_model") ? [MODEL_ROW] : [], + ), + begin: (cb: (t: FakeTx) => Promise) => cb(tx), + }; + return rawSql as unknown as Parameters[0]["sql"]; + } + + function baseArgs(sql: Parameters[0]["sql"]) { + return { + sql, + embedClientConfig: { + baseUrl: "https://embed.example.com", + modelId: "m", + apiStyle: "openai" as const, + }, + fetchImpl: openaiEmbedFetch(), + tenantId: "tenant-1", + principalId: null, + query: "hello", + overfetchLimit: 250, + }; + } + + it("excludes a semantically-similar chunk whose document kind does not match `kinds`", async () => { + const rows = await fetchDenseCandidates({ + ...baseArgs(fakeRawSql()), + kinds: ["task"], + }); + const chunkIds = rows?.map((r) => r.chunkId) ?? []; + expect(chunkIds).toContain("chunk-task"); + expect(chunkIds).not.toContain("chunk-note"); + }); + + it("excludes a semantically-similar chunk whose document is not linked to any requested entityId", async () => { + const rows = await fetchDenseCandidates({ + ...baseArgs(fakeRawSql()), + entityIds: ["e-match"], + }); + const chunkIds = rows?.map((r) => r.chunkId) ?? []; + expect(chunkIds).toContain("chunk-task"); + expect(chunkIds).not.toContain("chunk-note"); + }); + + it("applies no kind/entity predicate — and returns every semantically-similar chunk — when neither filter is provided", async () => { + const rows = await fetchDenseCandidates(baseArgs(fakeRawSql())); + const chunkIds = rows?.map((r) => r.chunkId) ?? []; + expect(chunkIds).toContain("chunk-task"); + expect(chunkIds).toContain("chunk-note"); + }); + + it("treats an empty kinds/entityIds array as no filter, same as lexical", async () => { + const rows = await fetchDenseCandidates({ + ...baseArgs(fakeRawSql()), + kinds: [], + entityIds: [], + }); + const chunkIds = rows?.map((r) => r.chunkId) ?? []; + expect(chunkIds).toContain("chunk-task"); + expect(chunkIds).toContain("chunk-note"); + }); +}); diff --git a/src/services/search.ts b/src/services/search.ts index aa7af61..b648622 100644 --- a/src/services/search.ts +++ b/src/services/search.ts @@ -354,15 +354,22 @@ export async function attachEntityIds( return map; } -interface LexicalCandidateParams { +// The kinds/entityIds shape shared by both channels' candidate-query params +// and by HybridSearchArgs (which fans a single caller-supplied pair of these +// out to both channels before fusion). An empty array is treated identically +// to `undefined` — no filter — never "match nothing". +interface ChannelFilterFields { + kinds?: string[] | undefined; + entityIds?: string[] | undefined; +} + +interface LexicalCandidateParams extends ChannelFilterFields { db: Db; tenantId: string; principalId: string | null; query: string; ftsLanguage: string; overfetchLimit: number; - kinds?: string[] | undefined; - entityIds?: string[] | undefined; // Defaults to 'live' — every version-scoped query filters by generation so // a replayed generation's chunks never leak into a live search and vice // versa (the replay pipeline). @@ -458,7 +465,7 @@ export async function fetchLexicalCandidates( return rows as CandidateRow[]; } -interface FetchDenseCandidatesArgs { +interface FetchDenseCandidatesArgs extends ChannelFilterFields { sql: RawSql; embedClientConfig: EmbedClientConfig; fetchImpl: typeof fetch; @@ -513,6 +520,8 @@ export async function fetchDenseCandidates( principalId, query, overfetchLimit, + kinds, + entityIds, generation = LIVE_GENERATION, } = args; @@ -557,6 +566,26 @@ export async function fetchDenseCandidates( params.push(generation); const generationParam = `$${params.length}`; + // Mirrors fetchLexicalCandidates' kind/entity predicates so the dense + // channel never surfaces a document the caller asked to exclude — a fused + // hit must match the filter regardless of which channel found it (empty + // array === no filter, same as the lexical side). + let kindClause = ""; + if (kinds && kinds.length > 0) { + params.push(kinds); + kindClause = `AND kd.kind = ANY($${params.length}::text[])`; + } + + let entityClause = ""; + if (entityIds && entityIds.length > 0) { + params.push(entityIds); + entityClause = `AND kd.id IN ( + SELECT ke.from_ref FROM knowledge_edge ke + WHERE ke.tenant_id = $1 AND ke.from_type = 'document' AND ke.to_type = 'entity' + AND ke.to_ref = ANY($${params.length}::text[]) + )`; + } + const sqlText = ` SELECT c.id AS chunk_id, c.document_id AS document_id, c.version_id AS version_id, kv.version AS version, kv.status AS status, kd.title AS title, kd.kind AS kind, @@ -570,6 +599,8 @@ export async function fetchDenseCandidates( WHERE e.tenant_id = $1 AND c.tenant_id = $1 AND kv.status = 'active' AND kv.generation = ${generationParam} AND ${visibilitySql} + ${kindClause} + ${entityClause} ORDER BY ${cosineDistanceExpr("e.embedding", vectorParam, activeTable.dims)} ASC LIMIT ${limitParam} `; @@ -745,13 +776,17 @@ export interface HybridSearchDeps { now?: Date | undefined; } -export interface HybridSearchArgs { +export interface HybridSearchArgs extends ChannelFilterFields { query: string; tenantId: string; principalId: string | null; k?: number | undefined; - kinds?: string[] | undefined; - entityIds?: string[] | undefined; + // `kinds`/`entityIds` (ChannelFilterFields) are applied to BOTH retrieval + // channels (fetchLexicalCandidates and fetchDenseCandidates) before + // fusion, so a document that doesn't match is never a candidate on either + // leg — no post-fusion gap. An empty array does NOT count as "provided" + // for the empty-query-requires-a-structured-filter check below. + // // Defaults to 'live' — the normal capture/search behavior. A non-live // generation (a transform_run id, the replay pipeline) searches that replay's corpus // instead, applying its transform_config's retrieval tuning when @@ -780,6 +815,10 @@ export interface HybridSearchResult { * budget for the document, so the request was skipped rather than sent * guaranteed to exceed the model's token limit (see `RerankQueryTooLongError` * in rerank-client.ts). + * + * `kinds`/`entityIds` narrow BOTH channels' candidate queries (see + * `HybridSearchArgs`) before fusion runs, so every hit in the fused result + * matches the requested kind/entity — there is no dense-only escape hatch. */ export async function hybridSearch( deps: HybridSearchDeps, @@ -851,6 +890,8 @@ export async function hybridSearch( principalId, query, overfetchLimit, + kinds, + entityIds, generation, }); if (dense === null) {