Skip to content

Commit a32fbb0

Browse files
committed
fix(knowledge): require an enabled chunk when a tag filter decides a document
A document whose only tagged chunk is disabled could be admitted by the probe and then discarded by ranking, spending the probe's document bound on a document that can contribute no candidate. Update the KB block fan-out integration test to the strategy it now exercises: a scope too small to fill the traversal probes once and rescues once.
1 parent 18279e4 commit a32fbb0

3 files changed

Lines changed: 45 additions & 6 deletions

File tree

‎apps/sim/lib/knowledge/__integration__/kb-block-search.integration.ts‎

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ describe('API-key KB block fan-out', () => {
9191
const previousDebug = db.$client.options.debug
9292
const statements: string[] = []
9393
db.$client.options.debug = (_connection, query) => {
94-
if (statements.length < 250) statements.push(query)
94+
if (statements.length < 1000) statements.push(query)
9595
}
9696
try {
9797
const results = await Promise.all(
@@ -124,9 +124,26 @@ describe('API-key KB block fan-out', () => {
124124
expect(result.rows[0].knowledgeBaseId).toBe(bases[index].id)
125125
expect(result.rows[0].distance).toBeCloseTo(0)
126126
}
127-
expect(statements.filter((query) => query.includes('statement_timeout'))).toHaveLength(54)
128-
expect(statements.filter((query) => query.includes('+ 0'))).toHaveLength(18)
129-
expect(statements.some((query) => query.includes('hnsw.iterative_scan'))).toBe(false)
127+
const matching = (fragment: string) =>
128+
statements.filter((query) => query.includes(fragment))
129+
/**
130+
* Every statement runs under the leg's deadline: the candidate search reinstates it after
131+
* tuning the scan, and the probe, the exact ranking, the rerank and hydration each open
132+
* with one of their own.
133+
*/
134+
expect(matching('statement_timeout')).toHaveLength(bases.length * 6)
135+
/**
136+
* A scope this small leaves the bounded traversal short of its candidate limit, so every
137+
* search probes once and rescues once — never a widening retry loop.
138+
*/
139+
expect(matching('hnsw.iterative_scan')).toHaveLength(bases.length)
140+
expect(matching('AS visible')).toHaveLength(bases.length)
141+
expect(matching(') + 0 LIMIT')).toHaveLength(bases.length)
142+
expect(matching('scored_search_candidates')).toHaveLength(bases.length)
143+
/** The probe enumerates visible documents; it never ranks them. */
144+
expect(
145+
statements.filter((query) => query.includes('AS id FROM') && !query.includes('ORDER BY'))
146+
).toHaveLength(bases.length)
130147
} finally {
131148
db.$client.options.debug = previousDebug
132149
}

‎apps/sim/lib/knowledge/search/queries.test.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,6 +437,24 @@ describe('workspace-scoped vector retrieval', () => {
437437
expect(getForConnectors).not.toHaveBeenCalled()
438438
})
439439

440+
it('counts only chunks the search can return when a tag filter decides a document', async () => {
441+
traversedRows = ranked
442+
probeRows = [{ id: 'near-doc' }]
443+
exactRows = ranked
444+
queueTableRows(schemaMock.embedding, [...ranked].reverse())
445+
await handleTagAndVectorSearch({
446+
...params,
447+
structuredFilters: [{ tagSlot: 'tag1', fieldType: 'text', operator: 'eq', value: 'common' }],
448+
})
449+
/**
450+
* A document whose only tagged chunk is disabled contributes no candidate, so admitting it
451+
* would spend the probe's document bound on a document the ranking then discards.
452+
*/
453+
const probe = JSON.stringify(statements().find((query) => isProbeStatement(query.sql))!)
454+
expect(probe).toContain(String(schemaMock.embedding.tag1))
455+
expect(probe).toContain(`"left":"${schemaMock.embedding.enabled}","right":true`)
456+
})
457+
440458
it('keeps the tuple budget an order of magnitude above the beam so the scan can iterate', async () => {
441459
queueTableRows(schemaMock.embedding, [...ranked].reverse())
442460
await handleVectorOnlySearch(params)

‎apps/sim/lib/knowledge/search/queries.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -847,12 +847,16 @@ async function probeVisibleDocuments(
847847
}
848848
}
849849

850-
/** Tags live on chunks, so a row qualifies when a chunk it joins to carries them. */
850+
/**
851+
* Tags live on chunks, so a row qualifies when a chunk it joins to carries them — and only a
852+
* chunk the search can actually return counts, or a document whose sole match is disabled would
853+
* be admitted by a check that ranking then discards.
854+
*/
851855
function chunkTagCondition(join: SQL, tagConditions: SQL[]): SQL | undefined {
852856
if (!tagConditions.length) return undefined
853857
return sql`EXISTS (
854858
SELECT 1 FROM ${embedding}
855-
WHERE ${and(join, ...tagConditions)}
859+
WHERE ${and(join, eq(embedding.enabled, true), ...tagConditions)}
856860
)`
857861
}
858862

0 commit comments

Comments
 (0)