Skip to content

Commit 3a6edaf

Browse files
committed
feat(knowledge): hybrid lexical + vector retrieval for KB search
KB search ranked purely on pgvector cosine distance, which retrieves exact tokens (error codes, ticket keys, identifiers, rare product names) poorly. Add a full-text leg over the already-present generated `embedding.content_tsv` column and its GIN index — no migration, no re-indexing — and fuse it with the vector leg by reciprocal rank. Both legs run concurrently and share the same visibility and tag-filter predicates; the lexical leg is best-effort and falls back to vector-only on failure. Hybrid is the default for every caller. `searchMode: 'vector'` on the internal and v1 contracts (and an advanced Retrieval Mode dropdown on the Knowledge block) restores the previous behavior. Both search routes now share one `executeKnowledgeSearch` dispatch instead of duplicating the three-branch retrieval logic.
1 parent 48aeac2 commit 3a6edaf

11 files changed

Lines changed: 547 additions & 95 deletions

File tree

apps/sim/app/api/knowledge/search/route.test.ts

Lines changed: 63 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -21,17 +21,13 @@ import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vites
2121

2222
const {
2323
mockGetDocumentTagDefinitions,
24-
mockHandleTagOnlySearch,
25-
mockHandleVectorOnlySearch,
26-
mockHandleTagAndVectorSearch,
24+
mockExecuteKnowledgeSearch,
2725
mockGetQueryStrategy,
2826
mockGenerateSearchEmbedding,
2927
mockGetDocumentMetadataByIds,
3028
} = vi.hoisted(() => ({
3129
mockGetDocumentTagDefinitions: vi.fn(),
32-
mockHandleTagOnlySearch: vi.fn(),
33-
mockHandleVectorOnlySearch: vi.fn(),
34-
mockHandleTagAndVectorSearch: vi.fn(),
30+
mockExecuteKnowledgeSearch: vi.fn(),
3531
mockGetQueryStrategy: vi.fn(),
3632
mockGenerateSearchEmbedding: vi.fn(),
3733
mockGetDocumentMetadataByIds: vi.fn(),
@@ -69,9 +65,7 @@ vi.mock('@/lib/knowledge/tags/service', () => ({
6965
}))
7066

7167
vi.mock('./utils', () => ({
72-
handleTagOnlySearch: mockHandleTagOnlySearch,
73-
handleVectorOnlySearch: mockHandleVectorOnlySearch,
74-
handleTagAndVectorSearch: mockHandleTagAndVectorSearch,
68+
executeKnowledgeSearch: mockExecuteKnowledgeSearch,
7569
getQueryStrategy: mockGetQueryStrategy,
7670
generateSearchEmbedding: mockGenerateSearchEmbedding,
7771
getDocumentMetadataByIds: mockGetDocumentMetadataByIds,
@@ -118,9 +112,7 @@ describe('Knowledge Search API Route', () => {
118112
resetDbChainMock()
119113
setEnv({ OPENAI_API_KEY: 'test-api-key' })
120114

121-
mockHandleTagOnlySearch.mockClear()
122-
mockHandleVectorOnlySearch.mockClear()
123-
mockHandleTagAndVectorSearch.mockClear()
115+
mockExecuteKnowledgeSearch.mockClear()
124116
mockGetQueryStrategy.mockClear().mockReturnValue({
125117
useParallel: false,
126118
distanceThreshold: 1.0,
@@ -192,7 +184,7 @@ describe('Knowledge Search API Route', () => {
192184

193185
dbChainMockFns.limit.mockResolvedValue([])
194186

195-
mockHandleVectorOnlySearch.mockResolvedValue(mockSearchResults)
187+
mockExecuteKnowledgeSearch.mockResolvedValue(mockSearchResults)
196188

197189
mockFetch.mockResolvedValue({
198190
ok: true,
@@ -212,14 +204,50 @@ describe('Knowledge Search API Route', () => {
212204
expect(data.data.results[0].similarity).toBe(0.8) // 1 - 0.2
213205
expect(data.data.query).toBe(validSearchData.query)
214206
expect(data.data.knowledgeBaseIds).toEqual(['kb-123'])
215-
expect(mockHandleVectorOnlySearch).toHaveBeenCalledWith({
207+
expect(mockExecuteKnowledgeSearch).toHaveBeenCalledWith({
216208
knowledgeBaseIds: ['kb-123'],
217209
topK: 10,
210+
searchMode: 'hybrid',
211+
query: validSearchData.query,
218212
queryVector: JSON.stringify(mockEmbedding),
219-
distanceThreshold: expect.any(Number),
213+
structuredFilters: undefined,
220214
})
221215
})
222216

217+
it('should forward the searchMode opt-out to the retrieval layer', async () => {
218+
mockGetUserId.mockResolvedValue('user-123')
219+
220+
mockCheckKnowledgeBaseAccess.mockResolvedValue({
221+
hasAccess: true,
222+
knowledgeBase: {
223+
id: 'kb-123',
224+
userId: 'user-123',
225+
name: 'Test KB',
226+
deletedAt: null,
227+
},
228+
})
229+
230+
dbChainMockFns.limit.mockResolvedValue([])
231+
232+
mockExecuteKnowledgeSearch.mockResolvedValue(mockSearchResults)
233+
234+
mockFetch.mockResolvedValue({
235+
ok: true,
236+
json: () =>
237+
Promise.resolve({
238+
data: [{ embedding: mockEmbedding }],
239+
}),
240+
})
241+
242+
const req = createMockRequest('POST', { ...validSearchData, searchMode: 'vector' })
243+
const response = await POST(req)
244+
245+
expect(response.status).toBe(200)
246+
expect(mockExecuteKnowledgeSearch).toHaveBeenCalledWith(
247+
expect.objectContaining({ searchMode: 'vector' })
248+
)
249+
})
250+
223251
it('should perform search successfully with multiple knowledge bases', async () => {
224252
const multiKbData = {
225253
...validSearchData,
@@ -239,7 +267,7 @@ describe('Knowledge Search API Route', () => {
239267

240268
dbChainMockFns.limit.mockResolvedValue([])
241269

242-
mockHandleVectorOnlySearch.mockResolvedValue(mockSearchResults)
270+
mockExecuteKnowledgeSearch.mockResolvedValue(mockSearchResults)
243271

244272
mockFetch.mockResolvedValue({
245273
ok: true,
@@ -256,11 +284,13 @@ describe('Knowledge Search API Route', () => {
256284
expect(response.status).toBe(200)
257285
expect(data.success).toBe(true)
258286
expect(data.data.knowledgeBaseIds).toEqual(['kb-123', 'kb-456'])
259-
expect(mockHandleVectorOnlySearch).toHaveBeenCalledWith({
287+
expect(mockExecuteKnowledgeSearch).toHaveBeenCalledWith({
260288
knowledgeBaseIds: ['kb-123', 'kb-456'],
261289
topK: 10,
290+
searchMode: 'hybrid',
291+
query: multiKbData.query,
262292
queryVector: JSON.stringify(mockEmbedding),
263-
distanceThreshold: expect.any(Number),
293+
structuredFilters: undefined,
264294
})
265295
})
266296

@@ -284,7 +314,7 @@ describe('Knowledge Search API Route', () => {
284314

285315
dbChainMockFns.limit.mockResolvedValue([])
286316

287-
mockHandleVectorOnlySearch.mockResolvedValue(mockSearchResults)
317+
mockExecuteKnowledgeSearch.mockResolvedValue(mockSearchResults)
288318

289319
mockFetch.mockResolvedValue({
290320
ok: true,
@@ -348,7 +378,7 @@ describe('Knowledge Search API Route', () => {
348378
embeddingModel: 'text-embedding-3-small',
349379
},
350380
})
351-
mockHandleVectorOnlySearch.mockResolvedValue(mockSearchResults)
381+
mockExecuteKnowledgeSearch.mockResolvedValue(mockSearchResults)
352382
const attribution = encodeURIComponent(
353383
JSON.stringify({
354384
actorUserId: 'user-123',
@@ -532,7 +562,7 @@ describe('Knowledge Search API Route', () => {
532562
mockGetUserId.mockResolvedValue('user-123')
533563
dbChainMockFns.limit.mockResolvedValueOnce(mockKnowledgeBases)
534564

535-
mockHandleVectorOnlySearch.mockRejectedValueOnce(new Error('Database error'))
565+
mockExecuteKnowledgeSearch.mockRejectedValueOnce(new Error('Database error'))
536566

537567
const req = createMockRequest('POST', validSearchData)
538568
const response = await POST(req)
@@ -750,7 +780,7 @@ describe('Knowledge Search API Route', () => {
750780

751781
dbChainMockFns.limit.mockResolvedValueOnce(mockTagDefinitions)
752782

753-
mockHandleTagOnlySearch.mockResolvedValue(mockTaggedResults)
783+
mockExecuteKnowledgeSearch.mockResolvedValue(mockTaggedResults)
754784

755785
const req = createMockRequest('POST', tagOnlyData)
756786
const response = await POST(req)
@@ -763,9 +793,10 @@ describe('Knowledge Search API Route', () => {
763793
expect(data.data.query).toBe('') // Empty query
764794
expect(data.data.cost).toBeUndefined() // No cost for tag-only search
765795
expect(mockGenerateSearchEmbedding).not.toHaveBeenCalled() // No embedding API call
766-
expect(mockHandleTagOnlySearch).toHaveBeenCalledWith({
796+
expect(mockExecuteKnowledgeSearch).toHaveBeenCalledWith({
767797
knowledgeBaseIds: ['kb-123'],
768798
topK: 10,
799+
searchMode: 'hybrid',
769800
structuredFilters: [
770801
{ tagSlot: 'tag1', fieldType: 'text', operator: 'eq', value: 'api', valueTo: undefined },
771802
],
@@ -796,7 +827,7 @@ describe('Knowledge Search API Route', () => {
796827

797828
dbChainMockFns.limit.mockResolvedValueOnce(mockTagDefinitions)
798829

799-
mockHandleTagAndVectorSearch.mockResolvedValue(mockSearchResults)
830+
mockExecuteKnowledgeSearch.mockResolvedValue(mockSearchResults)
800831

801832
mockFetch.mockResolvedValue({
802833
ok: true,
@@ -816,14 +847,15 @@ describe('Knowledge Search API Route', () => {
816847
expect(data.data.query).toBe('test search')
817848
expect(data.data.cost).toBeDefined() // Cost included for vector search
818849
expect(mockGenerateSearchEmbedding).toHaveBeenCalled() // Embedding API called
819-
expect(mockHandleTagAndVectorSearch).toHaveBeenCalledWith({
850+
expect(mockExecuteKnowledgeSearch).toHaveBeenCalledWith({
820851
knowledgeBaseIds: ['kb-123'],
821852
topK: 10,
853+
searchMode: 'hybrid',
854+
query: 'test search',
855+
queryVector: JSON.stringify(mockEmbedding),
822856
structuredFilters: [
823857
{ tagSlot: 'tag1', fieldType: 'text', operator: 'eq', value: 'api', valueTo: undefined },
824858
],
825-
queryVector: JSON.stringify(mockEmbedding),
826-
distanceThreshold: 1, // Single KB uses threshold of 1.0
827859
})
828860
})
829861

@@ -987,7 +1019,7 @@ describe('Knowledge Search API Route', () => {
9871019

9881020
mockGetDocumentTagDefinitions.mockResolvedValue(mockTagDefinitions)
9891021

990-
mockHandleTagOnlySearch.mockResolvedValue(mockTaggedResults)
1022+
mockExecuteKnowledgeSearch.mockResolvedValue(mockTaggedResults)
9911023

9921024
dbChainMockFns.limit.mockResolvedValueOnce(mockTagDefinitions)
9931025

@@ -1016,7 +1048,7 @@ describe('Knowledge Search API Route', () => {
10161048
},
10171049
})
10181050

1019-
mockHandleVectorOnlySearch.mockResolvedValue([
1051+
mockExecuteKnowledgeSearch.mockResolvedValue([
10201052
{
10211053
id: 'chunk-1',
10221054
content: 'Content from active document',
@@ -1092,7 +1124,7 @@ describe('Knowledge Search API Route', () => {
10921124
{ tagSlot: 'tag1', displayName: 'tag1', fieldType: 'text' },
10931125
])
10941126

1095-
mockHandleTagOnlySearch.mockResolvedValue([
1127+
mockExecuteKnowledgeSearch.mockResolvedValue([
10961128
{
10971129
id: 'chunk-2',
10981130
content: 'Content from active document with tag',
@@ -1164,7 +1196,7 @@ describe('Knowledge Search API Route', () => {
11641196
{ tagSlot: 'tag1', displayName: 'tag1', fieldType: 'text' },
11651197
])
11661198

1167-
mockHandleTagAndVectorSearch.mockResolvedValue([
1199+
mockExecuteKnowledgeSearch.mockResolvedValue([
11681200
{
11691201
id: 'chunk-3',
11701202
content: 'Relevant content from active document',

apps/sim/app/api/knowledge/search/route.ts

Lines changed: 12 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,9 @@ import { buildUndefinedTagsError, validateTagValue } from '@/lib/knowledge/tags/
2727
import type { StructuredFilter } from '@/lib/knowledge/types'
2828
import { estimateTokenCount } from '@/lib/tokenization/estimators'
2929
import {
30+
executeKnowledgeSearch,
3031
generateSearchEmbedding,
3132
getDocumentMetadataByIds,
32-
getQueryStrategy,
33-
handleTagAndVectorSearch,
34-
handleTagOnlySearch,
35-
handleVectorOnlySearch,
3633
type SearchResult,
3734
} from '@/app/api/knowledge/search/utils'
3835
import { checkKnowledgeBaseAccess, type KnowledgeBaseAccessResult } from '@/app/api/knowledge/utils'
@@ -318,32 +315,26 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
318315
: validatedData.topK
319316

320317
if (!hasQuery && hasFilters) {
321-
results = await handleTagOnlySearch({
318+
results = await executeKnowledgeSearch({
322319
knowledgeBaseIds: accessibleKbIds,
323320
topK: validatedData.topK,
321+
searchMode: validatedData.searchMode,
324322
structuredFilters,
325323
})
326-
} else if (hasQuery && hasFilters) {
327-
logger.debug(`[${requestId}] Executing tag + vector search with filters:`, structuredFilters)
328-
const strategy = getQueryStrategy(accessibleKbIds.length, candidateTopK)
329-
const queryVector = JSON.stringify((await queryEmbeddingPromise)?.embedding ?? null)
330-
331-
results = await handleTagAndVectorSearch({
332-
knowledgeBaseIds: accessibleKbIds,
333-
topK: candidateTopK,
334-
structuredFilters,
335-
queryVector,
336-
distanceThreshold: strategy.distanceThreshold,
337-
})
338-
} else if (hasQuery && !hasFilters) {
339-
const strategy = getQueryStrategy(accessibleKbIds.length, candidateTopK)
324+
} else if (hasQuery) {
325+
logger.debug(
326+
`[${requestId}] Executing ${validatedData.searchMode} search`,
327+
hasFilters ? { structuredFilters } : undefined
328+
)
340329
const queryVector = JSON.stringify((await queryEmbeddingPromise)?.embedding ?? null)
341330

342-
results = await handleVectorOnlySearch({
331+
results = await executeKnowledgeSearch({
343332
knowledgeBaseIds: accessibleKbIds,
344333
topK: candidateTopK,
334+
searchMode: validatedData.searchMode,
335+
query: validatedData.query,
345336
queryVector,
346-
distanceThreshold: strategy.distanceThreshold,
337+
structuredFilters: hasFilters ? structuredFilters : undefined,
347338
})
348339
} else {
349340
return NextResponse.json(

0 commit comments

Comments
 (0)