Skip to content

Commit b8d12bb

Browse files
j15zclaude
andcommitted
fix(copilot): explain a short or empty search_docs result set
The SQL LIMIT is applied before the similarity-threshold and liveness filters, so search_docs can return fewer hits than topK — or none, when every candidate was filtered. An empty array is indistinguishable from "the documentation does not cover this", which sends the agent off to guess instead of rephrasing or falling back to glob. searchDocs now returns the drop counts alongside the results, and the tool attaches a note when anything was dropped: how many candidates the index returned, why they went, and what to try next. Silent on the common path. This does not change which rows are returned or how many — the ordering issue behind the shortfall is a pre-existing bug the deleted search-documentation.ts had too, and pushing the threshold into SQL is its own change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent b8409d5 commit b8d12bb

3 files changed

Lines changed: 134 additions & 12 deletions

File tree

apps/sim/lib/copilot/docs/docs-search.test.ts

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ describe('searchDocs results', () => {
124124
similarity: 0.8,
125125
},
126126
]
127-
const results = await searchDocs('cron')
127+
const { results } = await searchDocs('cron')
128128
expect(results).toEqual([
129129
{
130130
path: 'docs/workflows.mdx',
@@ -153,7 +153,7 @@ describe('searchDocs results', () => {
153153
similarity: 0.9,
154154
},
155155
]
156-
expect(await searchDocs('cron')).toEqual([])
156+
expect((await searchDocs('cron')).results).toEqual([])
157157
})
158158

159159
it('drops chunks below the similarity threshold', async () => {
@@ -166,6 +166,55 @@ describe('searchDocs results', () => {
166166
similarity: 0.1,
167167
},
168168
]
169-
expect(await searchDocs('cron')).toEqual([])
169+
expect((await searchDocs('cron')).results).toEqual([])
170+
})
171+
})
172+
173+
describe('searchDocs shortfall reporting', () => {
174+
beforeEach(() => {
175+
capturedWhere.value = undefined
176+
mockGenerateSearchEmbedding.mockResolvedValue({ embedding: [0.1, 0.2] })
177+
})
178+
179+
it('counts why candidates were dropped so an empty set is explainable', async () => {
180+
mockRows.value = [
181+
{
182+
chunkText: 'a',
183+
sourceDocument: 'agents.mdx',
184+
sourceLink: 'x',
185+
headerText: 'h',
186+
similarity: 0.1,
187+
},
188+
{
189+
chunkText: 'b',
190+
sourceDocument: 'deleted-page.mdx',
191+
sourceLink: 'y',
192+
headerText: 'h',
193+
similarity: 0.9,
194+
},
195+
]
196+
const outcome = await searchDocs('cron')
197+
expect(outcome).toEqual({
198+
results: [],
199+
candidatesConsidered: 2,
200+
droppedBelowThreshold: 1,
201+
droppedStale: 1,
202+
})
203+
})
204+
205+
it('reports no drops when every candidate survives', async () => {
206+
mockRows.value = [
207+
{
208+
chunkText: 'a',
209+
sourceDocument: 'agents.mdx',
210+
sourceLink: 'x',
211+
headerText: 'h',
212+
similarity: 0.9,
213+
},
214+
]
215+
const outcome = await searchDocs('cron')
216+
expect(outcome.droppedBelowThreshold).toBe(0)
217+
expect(outcome.droppedStale).toBe(0)
218+
expect(outcome.results).toHaveLength(1)
170219
})
171220
})

apps/sim/lib/copilot/docs/docs-search.ts

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,21 @@ export interface DocsSearchResult {
2727
* section in the docs corpus. Surfaced verbatim so the model can correct itself
2828
* rather than reading an empty result as "the docs say nothing about this".
2929
*/
30+
/**
31+
* A search result set plus why it may be shorter than `topK`. The SQL LIMIT is
32+
* applied before the threshold and liveness filters, so these counts are what
33+
* distinguishes "nothing matched" from "matches were filtered out".
34+
*/
35+
export interface DocsSearchOutcome {
36+
results: DocsSearchResult[]
37+
/** Rows the vector search returned before filtering. */
38+
candidatesConsidered: number
39+
/** Candidates dropped for scoring below the similarity threshold. */
40+
droppedBelowThreshold: number
41+
/** Candidates dropped because their page is no longer in the docs manifest. */
42+
droppedStale: number
43+
}
44+
3045
export class DocsSearchScopeError extends Error {
3146
readonly code = 'DOCS_SEARCH_SCOPE' as const
3247
constructor(message: string) {
@@ -94,11 +109,16 @@ function escapeLikePattern(value: string): string {
94109
* The index lags the VFS: a page added since the last index rebuild is readable
95110
* but not searchable, and a deleted one can still return chunks. Results whose
96111
* source no longer maps to a live `docs/` path are dropped.
112+
*
113+
* Because those drops happen after the SQL LIMIT, a caller can get fewer hits
114+
* than it asked for — or none at all when every candidate was filtered. The
115+
* returned {@link DocsSearchOutcome} reports that explicitly so an empty result
116+
* is never mistaken for "the documentation does not cover this".
97117
*/
98118
export async function searchDocs(
99119
query: string,
100120
options?: { path?: string; topK?: number }
101-
): Promise<DocsSearchResult[]> {
121+
): Promise<DocsSearchOutcome> {
102122
if (!query || typeof query !== 'string') throw new Error('query is required')
103123

104124
const topK = Math.min(Math.max(Math.trunc(options?.topK ?? DEFAULT_TOP_K), 1), MAX_TOP_K)
@@ -107,7 +127,9 @@ export async function searchDocs(
107127
logger.info('Executing docs search', { query, topK, path: options?.path ?? null })
108128

109129
const { embedding: queryEmbedding } = await generateSearchEmbedding(query)
110-
if (!queryEmbedding || queryEmbedding.length === 0) return []
130+
if (!queryEmbedding || queryEmbedding.length === 0) {
131+
return { results: [], candidatesConsidered: 0, droppedBelowThreshold: 0, droppedStale: 0 }
132+
}
111133

112134
const rows = await db
113135
.select({
@@ -123,10 +145,18 @@ export async function searchDocs(
123145
.limit(topK)
124146

125147
const results: DocsSearchResult[] = []
148+
let droppedBelowThreshold = 0
149+
let droppedStale = 0
126150
for (const row of rows) {
127-
if (row.similarity < SIMILARITY_THRESHOLD) continue
151+
if (row.similarity < SIMILARITY_THRESHOLD) {
152+
droppedBelowThreshold++
153+
continue
154+
}
128155
const path = docsPathForSourceDocument(row.sourceDocument)
129-
if (!path) continue
156+
if (!path) {
157+
droppedStale++
158+
continue
159+
}
130160
results.push({
131161
path,
132162
url: String(row.sourceLink || '#'),
@@ -138,7 +168,13 @@ export async function searchDocs(
138168

139169
logger.info('Docs search complete', {
140170
count: results.length,
141-
dropped: rows.length - results.length,
171+
droppedBelowThreshold,
172+
droppedStale,
142173
})
143-
return results
174+
return {
175+
results,
176+
candidatesConsidered: rows.length,
177+
droppedBelowThreshold,
178+
droppedStale,
179+
}
144180
}

apps/sim/lib/copilot/tools/server/docs/search-docs.ts

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { DocsSearchResult } from '@/lib/copilot/docs/docs-search'
12
import { searchDocs } from '@/lib/copilot/docs/docs-search'
23
import { SearchDocs } from '@/lib/copilot/generated/tool-catalog-v1'
34
import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool'
@@ -9,9 +10,39 @@ interface SearchDocsParams {
910
}
1011

1112
interface SearchDocsOutput {
12-
results: Awaited<ReturnType<typeof searchDocs>>
13+
results: DocsSearchResult[]
1314
query: string
1415
totalResults: number
16+
/**
17+
* Present only when the vector search matched chunks that were then filtered
18+
* out. Without it an empty result set reads as "the docs do not cover this",
19+
* which sends the caller off to guess instead of rephrasing or falling back
20+
* to glob.
21+
*/
22+
note?: string
23+
}
24+
25+
/**
26+
* Explain a short or empty result set in terms the caller can act on. Returns
27+
* undefined when nothing was dropped — the common case needs no commentary.
28+
*/
29+
function shortfallNote(outcome: Awaited<ReturnType<typeof searchDocs>>): string | undefined {
30+
const { results, candidatesConsidered, droppedBelowThreshold, droppedStale } = outcome
31+
if (droppedBelowThreshold === 0 && droppedStale === 0) return undefined
32+
33+
const reasons: string[] = []
34+
if (droppedBelowThreshold > 0)
35+
reasons.push(`${droppedBelowThreshold} scored too low to be relevant`)
36+
if (droppedStale > 0) {
37+
reasons.push(
38+
`${droppedStale} point at pages no longer in the docs (the search index lags the site)`
39+
)
40+
}
41+
const dropped = reasons.join(' and ')
42+
43+
return results.length === 0
44+
? `No relevant matches. The search index returned ${candidatesConsidered} candidate(s), but ${dropped} — this does NOT mean the docs lack this topic. Rephrase the query, widen it by dropping the path scope, or browse with glob("docs/**").`
45+
: `Returned ${results.length} of ${candidatesConsidered} candidate(s); ${dropped}. Rephrase or widen the query if these look off-topic.`
1546
}
1647

1748
/**
@@ -22,7 +53,13 @@ interface SearchDocsOutput {
2253
export const searchDocsServerTool: BaseServerTool<SearchDocsParams, SearchDocsOutput> = {
2354
name: SearchDocs.id,
2455
async execute(params: SearchDocsParams): Promise<SearchDocsOutput> {
25-
const results = await searchDocs(params.query, { path: params.path, topK: params.topK })
26-
return { results, query: params.query, totalResults: results.length }
56+
const outcome = await searchDocs(params.query, { path: params.path, topK: params.topK })
57+
const note = shortfallNote(outcome)
58+
return {
59+
results: outcome.results,
60+
query: params.query,
61+
totalResults: outcome.results.length,
62+
...(note ? { note } : {}),
63+
}
2764
},
2865
}

0 commit comments

Comments
 (0)