Skip to content

Commit 726560d

Browse files
committed
feat(search): enhance search response structure with low ranking results and no close matches
- Updated the search service to return low ranking results and a noCloseMatches flag in the response. - Modified the search worker to filter results based on relevance thresholds, improving search quality. - Adjusted related components and tests to accommodate the new response structure, ensuring consistent handling of search results across the application. - Documented the relevance filtering logic in the project context.
1 parent 4d10e57 commit 726560d

File tree

18 files changed

+716
-221
lines changed

18 files changed

+716
-221
lines changed

docs/agents/project-context.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ reference:
3636
and run worker/package scripts with `npm run <script> --workspace <name>`.
3737
- The main site lives in `services/site`. Root `npm run dev`, `npm run build`,
3838
`npm run test`, and similar commands forward to that workspace.
39+
- Search worker relevance thresholds (`M`, `R`, `noCloseMatches`): see
40+
[`search-relevance.md`](./search-relevance.md).
3941
- Playwright already launches Chromium with fake media permissions/device input
4042
plus `tests/sample.wav`. If an e2e needs recorded audio, drive the real
4143
recorder UI and keep the fake-audio setup in Playwright/helpers rather than

docs/agents/search-relevance.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# Search relevance tuning
2+
3+
Worker: [`services/search-worker/src/search-results.ts`](../../services/search-worker/src/search-results.ts).
4+
5+
| Constant | Role |
6+
|----------|------|
7+
| `SEARCH_CONFIDENCE_MIN_BEST_SCORE` (`0.013`) | If the best fused RRF score is below this, return no results and `noCloseMatches: true`. |
8+
| `SEARCH_CONFIDENCE_RELATIVE_RATIO` (`0.5`) | Keep only hits with `score >= maxScore * ratio` (then cap at `topK`). |
9+
| `SEARCH_LOW_RANKING_MAX` (`35`) | Extra hits returned as `lowRankingResults` for the search page “Show low ranking results” control. |
10+
11+
Fused scores are on a small scale (~0.016 for a single-list #1, ~0.035 for strong dual-signal). Adjust in staging if results are over- or under-filtered.
12+
13+
Site cache key prefix: `search:kcd:v3:` (payload includes `noCloseMatches` and `lowRankingResults`).

services/search-shared/src/search-shared.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ export type SearchWorkerSearchResponse =
3333
| {
3434
ok: true
3535
results: Array<SearchResult>
36+
/** Candidates below primary confidence / beyond topK; for optional UI. */
37+
lowRankingResults?: Array<SearchResult>
38+
/** True when candidates existed but none met confidence thresholds. */
39+
noCloseMatches?: boolean
3640
}
3741
| {
3842
ok: false

services/search-worker/src/index.test.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,11 @@ function createEnv(): Env {
2121
function createService() {
2222
return {
2323
health: vi.fn(async () => ({ syncedAt: '2026-03-17T00:00:00.000Z' })),
24-
search: vi.fn(async () => [{ id: 'blog:hello-world', score: 0.9 }]),
24+
search: vi.fn(async () => ({
25+
results: [{ id: 'blog:hello-world', score: 0.9 }],
26+
lowRankingResults: [],
27+
noCloseMatches: false,
28+
})),
2529
sync: vi.fn(async () => ({ syncedAt: '2026-03-17T00:00:00.000Z' })),
2630
}
2731
}
@@ -91,6 +95,8 @@ test('search endpoint returns fused results', async () => {
9195
expect(await response.json()).toEqual({
9296
ok: true,
9397
results: [{ id: 'blog:hello-world', score: 0.9 }],
98+
lowRankingResults: [],
99+
noCloseMatches: false,
94100
})
95101
})
96102

services/search-worker/src/index.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,14 @@ export async function handleRequest({
9494
if (request.method !== 'POST') return methodNotAllowed()
9595
const body = await parseJsonBody(request)
9696
const parsed = searchRequestSchema.parse(body)
97-
const results = await service.search(parsed)
98-
return json({ ok: true, results })
97+
const { results, lowRankingResults, noCloseMatches } =
98+
await service.search(parsed)
99+
return json({
100+
ok: true,
101+
results,
102+
lowRankingResults,
103+
noCloseMatches,
104+
})
99105
}
100106

101107
if (url.pathname === '/internal/sync') {
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import type { SearchResult } from '@kcd-internal/search-shared'
2+
import { expect, test } from 'vitest'
3+
import {
4+
filterFusedResultsByConfidence,
5+
fuseRankedResultsAll,
6+
SEARCH_CONFIDENCE_MIN_BEST_SCORE,
7+
SEARCH_CONFIDENCE_RELATIVE_RATIO,
8+
type RankedDocResult,
9+
} from './search-results'
10+
11+
function doc(id: string): SearchResult {
12+
return { id, score: 0 }
13+
}
14+
15+
test('filterFusedResultsByConfidence: empty input', () => {
16+
expect(
17+
filterFusedResultsByConfidence({ fusedSorted: [], topK: 10 }),
18+
).toEqual({ results: [], lowRankingResults: [], noCloseMatches: false })
19+
})
20+
21+
test('filterFusedResultsByConfidence: maxScore below minBestScore', () => {
22+
const fused = [{ ...doc('a'), score: 0.01 }]
23+
expect(
24+
filterFusedResultsByConfidence({
25+
fusedSorted: fused,
26+
topK: 10,
27+
minBestScore: SEARCH_CONFIDENCE_MIN_BEST_SCORE,
28+
}),
29+
).toEqual({
30+
results: [],
31+
lowRankingResults: [{ ...doc('a'), score: 0.01 }],
32+
noCloseMatches: true,
33+
})
34+
})
35+
36+
test('filterFusedResultsByConfidence: drops tail below relative ratio', () => {
37+
const fused = [
38+
{ ...doc('strong'), score: 0.04 },
39+
{ ...doc('weak'), score: 0.015 },
40+
]
41+
const threshold = 0.04 * 0.45
42+
expect(0.015 < threshold).toBe(true)
43+
expect(
44+
filterFusedResultsByConfidence({
45+
fusedSorted: fused,
46+
topK: 10,
47+
minBestScore: 0.01,
48+
relativeRatio: 0.45,
49+
}),
50+
).toEqual({
51+
results: [{ ...doc('strong'), score: 0.04 }],
52+
lowRankingResults: [{ ...doc('weak'), score: 0.015 }],
53+
noCloseMatches: false,
54+
})
55+
})
56+
57+
test('filterFusedResultsByConfidence: top hit always passes relative threshold when above minBest', () => {
58+
const fused = [
59+
{ ...doc('a'), score: 0.02 },
60+
{ ...doc('b'), score: 0.019 },
61+
]
62+
expect(
63+
filterFusedResultsByConfidence({
64+
fusedSorted: fused,
65+
topK: 10,
66+
minBestScore: 0.015,
67+
relativeRatio: 0.99,
68+
}),
69+
).toEqual({
70+
results: [{ ...doc('a'), score: 0.02 }],
71+
lowRankingResults: [{ ...doc('b'), score: 0.019 }],
72+
noCloseMatches: false,
73+
})
74+
})
75+
76+
test('fuseRankedResultsAll ranks dual-signal doc above single-source', () => {
77+
const fused = fuseRankedResultsAll({
78+
semanticResults: [
79+
{ rank: 0, result: { id: 'a', title: 'A', score: 0 } },
80+
{ rank: 1, result: { id: 'b', title: 'B', score: 0 } },
81+
],
82+
lexicalResults: [
83+
{ rank: 0, result: { id: 'a', title: 'A', score: 0 } },
84+
{ rank: 1, result: { id: 'noise', title: 'N', score: 0 } },
85+
],
86+
})
87+
expect(fused[0]?.id).toBe('a')
88+
expect(filterFusedResultsByConfidence({ fusedSorted: fused, topK: 5 }).noCloseMatches).toBe(
89+
false,
90+
)
91+
})

services/search-worker/src/search-results.ts

Lines changed: 84 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,37 @@ export function fuseRankedResults({
164164
lexicalResults: Array<RankedDocResult>
165165
topK: number
166166
}) {
167+
return fuseRankedResultsAll({ semanticResults, lexicalResults }).slice(
168+
0,
169+
topK,
170+
)
171+
}
172+
173+
/** RRF-style fused scores; typical single-list #1 ~0.016–0.019, strong dual ~0.035. */
174+
export const SEARCH_CONFIDENCE_MIN_BEST_SCORE = 0.013
175+
/** Keep hits within this fraction of the top fused score (drops weak tail). */
176+
export const SEARCH_CONFIDENCE_RELATIVE_RATIO = 0.5
177+
/** Max extra hits returned for optional “show low ranking” UI. */
178+
export const SEARCH_LOW_RANKING_MAX = 35
179+
180+
function fuseMapToSortedResults(
181+
fused: Map<string, { score: number; result: SearchResult }>,
182+
): Array<SearchResult> {
183+
return [...fused.values()]
184+
.sort((left, right) => right.score - left.score)
185+
.map((entry) => ({
186+
...entry.result,
187+
score: entry.score,
188+
}))
189+
}
190+
191+
export function fuseRankedResultsAll({
192+
semanticResults,
193+
lexicalResults,
194+
}: {
195+
semanticResults: Array<RankedDocResult>
196+
lexicalResults: Array<RankedDocResult>
197+
}): Array<SearchResult> {
167198
const rankConstant = 60
168199
const weights = {
169200
semantic: 1,
@@ -224,13 +255,59 @@ export function fuseRankedResults({
224255
apply('semantic', semanticResults)
225256
apply('lexical', lexicalResults)
226257

227-
return [...fused.values()]
228-
.sort((left, right) => right.score - left.score)
229-
.slice(0, topK)
230-
.map((entry) => ({
231-
...entry.result,
232-
score: entry.score,
233-
}))
258+
return fuseMapToSortedResults(fused)
259+
}
260+
261+
export function filterFusedResultsByConfidence({
262+
fusedSorted,
263+
topK,
264+
minBestScore = SEARCH_CONFIDENCE_MIN_BEST_SCORE,
265+
relativeRatio = SEARCH_CONFIDENCE_RELATIVE_RATIO,
266+
}: {
267+
fusedSorted: Array<SearchResult>
268+
topK: number
269+
minBestScore?: number
270+
relativeRatio?: number
271+
}): {
272+
results: Array<SearchResult>
273+
lowRankingResults: Array<SearchResult>
274+
noCloseMatches: boolean
275+
} {
276+
if (fusedSorted.length === 0) {
277+
return { results: [], lowRankingResults: [], noCloseMatches: false }
278+
}
279+
280+
const capLow = (items: Array<SearchResult>) =>
281+
items.slice(0, SEARCH_LOW_RANKING_MAX)
282+
283+
const maxScore = fusedSorted[0]?.score ?? 0
284+
if (!Number.isFinite(maxScore) || maxScore < minBestScore) {
285+
return {
286+
results: [],
287+
lowRankingResults: capLow(fusedSorted),
288+
noCloseMatches: true,
289+
}
290+
}
291+
292+
const threshold = maxScore * relativeRatio
293+
const filtered = fusedSorted.filter((r) => r.score >= threshold)
294+
if (filtered.length === 0) {
295+
return {
296+
results: [],
297+
lowRankingResults: capLow(fusedSorted),
298+
noCloseMatches: true,
299+
}
300+
}
301+
302+
const primary = filtered.slice(0, topK)
303+
const primaryIds = new Set(primary.map((r) => r.id))
304+
const lowRanking = fusedSorted.filter((r) => !primaryIds.has(r.id))
305+
306+
return {
307+
results: primary,
308+
lowRankingResults: capLow(lowRanking),
309+
noCloseMatches: false,
310+
}
234311
}
235312

236313
export function normalizeYoutubeTimestampSeconds({

services/search-worker/src/search-service.test.ts

Lines changed: 68 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,12 +85,13 @@ test('search fuses lexical matches with semantic matches', async () => {
8585
}
8686
const service = createSearchService(createEnv(), dependencies)
8787

88-
const results = await service.search({
88+
const { results, noCloseMatches } = await service.search({
8989
query: 'How do I use useFetcher in React?',
9090
topK: 5,
9191
})
9292

9393
expect(dependencies.ensureSchema).toHaveBeenCalled()
94+
expect(noCloseMatches).toBe(false)
9495
expect(results).toHaveLength(2)
9596
expect(results[0]?.id).toBe('blog:react-hooks-pitfalls')
9697
expect(results[1]?.id).toBe('blog:some-other-post')
@@ -119,7 +120,7 @@ test('search preserves YouTube timestamps from lexical matches', async () => {
119120
}
120121
const service = createSearchService(createEnv(), dependencies)
121122

122-
const results = await service.search({
123+
const { results } = await service.search({
123124
query: 'shallow rendering',
124125
topK: 5,
125126
})
@@ -152,14 +153,78 @@ test('search with SEARCH_LEXICAL_ONLY skips embedding and Vectorize', async () =
152153
}
153154
const service = createSearchService(env, dependencies)
154155

155-
const results = await service.search({ query: 'test', topK: 5 })
156+
const { results } = await service.search({ query: 'test', topK: 5 })
156157

157158
expect(getEmbedding).not.toHaveBeenCalled()
158159
expect(queryVectorize).not.toHaveBeenCalled()
159160
expect(results).toHaveLength(1)
160161
expect(results[0]?.id).toBe('blog:only-lexical')
161162
})
162163

164+
test('search drops weak tail below relative confidence of top hit', async () => {
165+
const dependencies = {
166+
ensureSchema: vi.fn(async () => undefined),
167+
queryLexicalMatches: vi.fn(async () => [
168+
{
169+
id: 'blog:strong:chunk:0',
170+
type: 'blog',
171+
slug: 'strong',
172+
title: 'Strong',
173+
url: '/blog/strong',
174+
snippet: 's',
175+
},
176+
]),
177+
getEmbedding: vi.fn(async () => [0.1, 0.2, 0.3]),
178+
queryVectorize: vi.fn(async () => {
179+
const deep = Array.from({ length: 55 }, (_, i) => ({
180+
id: `blog:deep-${i}:chunk:0`,
181+
score: 0.9 - i * 0.01,
182+
metadata: {
183+
type: 'blog',
184+
slug: `deep-${i}`,
185+
title: `Deep ${i}`,
186+
url: `/blog/deep-${i}`,
187+
snippet: 'd',
188+
},
189+
}))
190+
return [
191+
{
192+
id: 'blog:strong:chunk:0',
193+
score: 0.99,
194+
metadata: {
195+
type: 'blog',
196+
slug: 'strong',
197+
title: 'Strong',
198+
url: '/blog/strong',
199+
snippet: 's',
200+
},
201+
},
202+
...deep,
203+
]
204+
}),
205+
syncArtifacts: vi.fn(async () => ({
206+
syncedAt: '2026-03-17T00:00:00.000Z',
207+
})),
208+
getSyncedAt: vi.fn(async () => '2026-03-17T00:00:00.000Z'),
209+
}
210+
const service = createSearchService(createEnv(), dependencies)
211+
212+
const { results, lowRankingResults, noCloseMatches } = await service.search({
213+
query: 'test',
214+
topK: 20,
215+
})
216+
217+
expect(noCloseMatches).toBe(false)
218+
expect(results.some((r) => r.id === 'blog:strong')).toBe(true)
219+
expect(
220+
results.filter((r) => r.id.startsWith('blog:deep-')).length,
221+
).toBeLessThanOrEqual(2)
222+
expect(results[0]?.id).toBe('blog:strong')
223+
expect(
224+
lowRankingResults.some((r) => r.id.startsWith('blog:deep-')),
225+
).toBe(true)
226+
})
227+
163228
test('search rejects overly long queries', async () => {
164229
const dependencies = {
165230
ensureSchema: vi.fn(async () => undefined),

0 commit comments

Comments
 (0)