Skip to content

Commit ed23330

Browse files
authored
feat(knowledge): opt-in hybrid lexical + vector retrieval for KB search (#6124)
* 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. * change(knowledge): make vector the default search mode, hybrid opt-in Every existing caller — workflow block, v1 API, copilot, guardrail RAG — keeps its current ranking. Hybrid retrieval is now requested explicitly via `searchMode: 'hybrid'`. Also routes the copilot knowledge tool through the shared `executeKnowledgeSearch` dispatch so all four callers share one retrieval path, and documents `searchMode` on the public v1 search endpoint in the OpenAPI spec. * docs(knowledge): document the hybrid retrieval mode Regenerates the knowledge integration reference for the new searchMode tool param, and adds a Retrieval Mode section to the knowledge base workflow guide explaining when hybrid beats vector-only. * fix(knowledge): stop rank fusion from starving the lexical leg Rank n in one leg always ties rank n in the other, so ordering the fused list by score alone let whichever leg was scored first take every tied slot. At topK=1 that meant a hybrid search returned exactly the vector-only result and discarded the exact keyword match the mode exists to recover. Selection now orders by score and drains each tie group round-robin, taking from whichever leg has contributed fewest rows so far. The lexical leg is passed first so it wins a total tie, since a chunk the vector leg ranked below its distance threshold is the case hybrid was opted into for. * fix(knowledge): credit a shared hit to every leg that returned it Attributing a row found by both legs to a single leg left the round-robin owing the other leg a slot it had already been served. With a shared rank-1 hit and topK 2, that evicted the lexical-only row — the exact match hybrid was enabled to recover — in favor of the vector-only one. A shared row satisfied every leg that returned it, so every one of them is now charged for it. Tie-breaking prefers the candidate whose least-served leg has been served least, which also removes the arbitrary best-rank attribution. * fix(knowledge): reject a whitespace-only copilot query explicitly The shared dispatch treats a whitespace-only query as absent and throws when no tag filters accompany it, where the previous vector-only call would have embedded the blank string and searched. Tighten the existing guard so the tool returns its normal message instead. * fix(knowledge): fan the keyword leg out per knowledge base The vector leg caps candidates per base once getQueryStrategy sets useParallel, but the keyword leg always ran one global query with a single LIMIT. Searching several bases at once let whichever one ranks strongest lexically consume every slot, so an exact-token hit in a smaller base never reached fusion — the case hybrid exists to serve. The keyword leg now uses the same strategy: per-base queries under the same parallel limit, re-ranked globally on a selected ts_rank_cd. Both legs draw candidates the same way, so fusion combines rankings over the same pool. * perf(knowledge): stop the keyword leg detoasting every match's vector Selecting the cosine distance in the ranking query made Postgres detoast the 1536-dimension embedding and compute a distance for every full-text match before the LIMIT applied, so cost tracked how common the query term was rather than topK. On a 20k-chunk base with a term matching every row that was 61,055 buffer hits against 1,030 for the same query without the projection. Rank on ids and ts_rank_cd alone, then hydrate only the rows that survive the limit. Same results, and the worst case drops to ~27ms end to end.
1 parent 898a10d commit ed23330

16 files changed

Lines changed: 834 additions & 140 deletions

File tree

apps/docs/content/docs/en/integrations/knowledge.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ Search for similar content in a knowledge base using vector similarity
4343
| `query` | string | No | Search query text \(optional when using tag filters\) |
4444
| `topK` | number | No | Number of most similar results to return \(1-100\) |
4545
| `tagFilters` | array | No | Array of tag filters with tagName and tagValue properties |
46+
| `searchMode` | string | No | Retrieval mode: 'vector' \(default\) uses semantic similarity only, 'hybrid' also runs a full-text leg and fuses both |
4647
| `rerankerEnabled` | boolean | No | Whether to apply Cohere reranking to vector search results |
4748
| `rerankerModel` | string | No | Cohere rerank model to use \(one of: rerank-v4.0-pro, rerank-v4.0-fast, rerank-v3.5\) |
4849
| `rerankerInputCount` | number | No | Number of vector results sent to the Cohere reranker \(1–100\). Defaults to topK × 4 capped at 100. |

apps/docs/content/docs/en/knowledgebase/using-in-workflows.mdx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,19 @@ In our example, adding `Department equals "Billing"` makes the search consider o
3737

3838
Filters run before the vector comparison, so they make a search both more precise and cheaper. See [Tags and filtering](/knowledgebase/tags) for the full operator list by tag type.
3939

40+
## Retrieval Mode
41+
42+
**Retrieval Mode** is an advanced setting that chooses how matches are found.
43+
44+
| Mode | What it does |
45+
| --- | --- |
46+
| Vector only | The default. Ranks purely on meaning, as described above. |
47+
| Hybrid | Also runs a keyword search over the same chunks and blends the two rankings. |
48+
49+
Semantic search is strong on paraphrase and weak on literal strings: an error code, a ticket key like `PROJ-1234`, a SKU, or a rare product name carries little meaning for the model, so the chunk containing it may not rank near the top. Hybrid adds a keyword pass that matches those tokens exactly, then merges the two lists so a chunk found by either signal can surface.
50+
51+
Turn it on when your documents are full of identifiers, codes, or names people search for verbatim. Leave it off for prose-heavy bases where questions are asked in natural language. Hybrid costs no extra API calls — the keyword pass runs entirely in the database.
52+
4053
## Rerank Results
4154

4255
**Rerank Results** is an optional second pass. Vector search ranks by raw similarity; reranking re-scores the top matches with a dedicated relevance model (Cohere's rerank models) and reorders them, which sharpens the ordering when the best answer isn't the literal closest vector.
@@ -95,6 +108,7 @@ When the agent's answer is off, the cause is usually in retrieval, not the agent
95108
- **No results, or wrong documents.** A tag filter may be excluding what you want, or the documents may not be indexed yet. A document is only searchable once its processing status is `completed`; while it is `pending`, `processing`, or `failed`, its chunks won't appear.
96109
- **Low similarity scores across the board.** The query is too vague, or the information simply isn't in the base. Rewrite the query to match how the documents phrase things.
97110
- **Right documents, wrong order.** Turn on Rerank Results, or raise Number of Results so the relevant chunk is included.
111+
- **An exact code, ID, or name isn't found.** Switch Retrieval Mode to Hybrid so a keyword pass runs alongside the semantic one.
98112

99113
See [debugging retrieval](/knowledgebase/debugging-retrieval) for the full diagnostic path, and [chunking strategies](/knowledgebase/chunking-strategies) for how chunk boundaries shape what a search can return.
100114

apps/docs/openapi.json

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6040,7 +6040,7 @@
60406040
"post": {
60416041
"operationId": "searchKnowledgeBase",
60426042
"summary": "Search Knowledge Base",
6043-
"description": "Perform vector similarity search across one or more knowledge bases. Supports semantic search via query text, tag-based filtering, or a combination of both.",
6043+
"description": "Search across one or more knowledge bases. Supports semantic search via query text, tag-based filtering, or a combination of both. Set `searchMode` to `hybrid` to additionally run a full-text keyword leg and fuse it with the semantic results.",
60446044
"tags": ["Knowledge Bases"],
60456045
"x-codeSamples": [
60466046
{
@@ -6095,14 +6095,21 @@
60956095
"items": {
60966096
"$ref": "#/components/schemas/TagFilter"
60976097
}
6098+
},
6099+
"searchMode": {
6100+
"type": "string",
6101+
"enum": ["vector", "hybrid"],
6102+
"default": "vector",
6103+
"description": "Retrieval strategy. `vector` ranks purely on embedding similarity. `hybrid` also runs a full-text keyword search and fuses the two rankings by reciprocal rank, which retrieves exact tokens — error codes, ticket keys, identifiers, rare product names — that embeddings alone rank poorly. Ignored when only tagFilters are provided."
60986104
}
60996105
}
61006106
},
61016107
"example": {
61026108
"workspaceId": "wsp_abc123",
61036109
"knowledgeBaseIds": ["d2c8f4a6-1b3e-4c5d-9e7f-8a0b2c4d6e1f"],
61046110
"query": "How do I reset my password?",
6105-
"topK": 5
6111+
"topK": 5,
6112+
"searchMode": "hybrid"
61066113
}
61076114
}
61086115
}

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

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

2222
const {
2323
mockGetDocumentTagDefinitions,
24-
mockHandleTagOnlySearch,
25-
mockHandleVectorOnlySearch,
26-
mockHandleTagAndVectorSearch,
27-
mockGetQueryStrategy,
24+
mockExecuteKnowledgeSearch,
2825
mockGenerateSearchEmbedding,
2926
mockGetDocumentMetadataByIds,
3027
} = vi.hoisted(() => ({
3128
mockGetDocumentTagDefinitions: vi.fn(),
32-
mockHandleTagOnlySearch: vi.fn(),
33-
mockHandleVectorOnlySearch: vi.fn(),
34-
mockHandleTagAndVectorSearch: vi.fn(),
35-
mockGetQueryStrategy: vi.fn(),
29+
mockExecuteKnowledgeSearch: vi.fn(),
3630
mockGenerateSearchEmbedding: vi.fn(),
3731
mockGetDocumentMetadataByIds: vi.fn(),
3832
}))
@@ -69,10 +63,7 @@ vi.mock('@/lib/knowledge/tags/service', () => ({
6963
}))
7064

7165
vi.mock('./utils', () => ({
72-
handleTagOnlySearch: mockHandleTagOnlySearch,
73-
handleVectorOnlySearch: mockHandleVectorOnlySearch,
74-
handleTagAndVectorSearch: mockHandleTagAndVectorSearch,
75-
getQueryStrategy: mockGetQueryStrategy,
66+
executeKnowledgeSearch: mockExecuteKnowledgeSearch,
7667
generateSearchEmbedding: mockGenerateSearchEmbedding,
7768
getDocumentMetadataByIds: mockGetDocumentMetadataByIds,
7869
APIError: class APIError extends Error {
@@ -118,15 +109,7 @@ describe('Knowledge Search API Route', () => {
118109
resetDbChainMock()
119110
setEnv({ OPENAI_API_KEY: 'test-api-key' })
120111

121-
mockHandleTagOnlySearch.mockClear()
122-
mockHandleVectorOnlySearch.mockClear()
123-
mockHandleTagAndVectorSearch.mockClear()
124-
mockGetQueryStrategy.mockClear().mockReturnValue({
125-
useParallel: false,
126-
distanceThreshold: 1.0,
127-
parallelLimit: 15,
128-
singleQueryOptimized: true,
129-
})
112+
mockExecuteKnowledgeSearch.mockClear()
130113
mockGenerateSearchEmbedding
131114
.mockClear()
132115
.mockResolvedValue({ embedding: [0.1, 0.2, 0.3, 0.4, 0.5], isBYOK: false })
@@ -192,7 +175,7 @@ describe('Knowledge Search API Route', () => {
192175

193176
dbChainMockFns.limit.mockResolvedValue([])
194177

195-
mockHandleVectorOnlySearch.mockResolvedValue(mockSearchResults)
178+
mockExecuteKnowledgeSearch.mockResolvedValue(mockSearchResults)
196179

197180
mockFetch.mockResolvedValue({
198181
ok: true,
@@ -212,14 +195,50 @@ describe('Knowledge Search API Route', () => {
212195
expect(data.data.results[0].similarity).toBe(0.8) // 1 - 0.2
213196
expect(data.data.query).toBe(validSearchData.query)
214197
expect(data.data.knowledgeBaseIds).toEqual(['kb-123'])
215-
expect(mockHandleVectorOnlySearch).toHaveBeenCalledWith({
198+
expect(mockExecuteKnowledgeSearch).toHaveBeenCalledWith({
216199
knowledgeBaseIds: ['kb-123'],
217200
topK: 10,
201+
searchMode: 'vector',
202+
query: validSearchData.query,
218203
queryVector: JSON.stringify(mockEmbedding),
219-
distanceThreshold: expect.any(Number),
204+
structuredFilters: undefined,
220205
})
221206
})
222207

208+
it('should forward the hybrid searchMode opt-in to the retrieval layer', async () => {
209+
mockGetUserId.mockResolvedValue('user-123')
210+
211+
mockCheckKnowledgeBaseAccess.mockResolvedValue({
212+
hasAccess: true,
213+
knowledgeBase: {
214+
id: 'kb-123',
215+
userId: 'user-123',
216+
name: 'Test KB',
217+
deletedAt: null,
218+
},
219+
})
220+
221+
dbChainMockFns.limit.mockResolvedValue([])
222+
223+
mockExecuteKnowledgeSearch.mockResolvedValue(mockSearchResults)
224+
225+
mockFetch.mockResolvedValue({
226+
ok: true,
227+
json: () =>
228+
Promise.resolve({
229+
data: [{ embedding: mockEmbedding }],
230+
}),
231+
})
232+
233+
const req = createMockRequest('POST', { ...validSearchData, searchMode: 'hybrid' })
234+
const response = await POST(req)
235+
236+
expect(response.status).toBe(200)
237+
expect(mockExecuteKnowledgeSearch).toHaveBeenCalledWith(
238+
expect.objectContaining({ searchMode: 'hybrid' })
239+
)
240+
})
241+
223242
it('should perform search successfully with multiple knowledge bases', async () => {
224243
const multiKbData = {
225244
...validSearchData,
@@ -239,7 +258,7 @@ describe('Knowledge Search API Route', () => {
239258

240259
dbChainMockFns.limit.mockResolvedValue([])
241260

242-
mockHandleVectorOnlySearch.mockResolvedValue(mockSearchResults)
261+
mockExecuteKnowledgeSearch.mockResolvedValue(mockSearchResults)
243262

244263
mockFetch.mockResolvedValue({
245264
ok: true,
@@ -256,11 +275,13 @@ describe('Knowledge Search API Route', () => {
256275
expect(response.status).toBe(200)
257276
expect(data.success).toBe(true)
258277
expect(data.data.knowledgeBaseIds).toEqual(['kb-123', 'kb-456'])
259-
expect(mockHandleVectorOnlySearch).toHaveBeenCalledWith({
278+
expect(mockExecuteKnowledgeSearch).toHaveBeenCalledWith({
260279
knowledgeBaseIds: ['kb-123', 'kb-456'],
261280
topK: 10,
281+
searchMode: 'vector',
282+
query: multiKbData.query,
262283
queryVector: JSON.stringify(mockEmbedding),
263-
distanceThreshold: expect.any(Number),
284+
structuredFilters: undefined,
264285
})
265286
})
266287

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

285306
dbChainMockFns.limit.mockResolvedValue([])
286307

287-
mockHandleVectorOnlySearch.mockResolvedValue(mockSearchResults)
308+
mockExecuteKnowledgeSearch.mockResolvedValue(mockSearchResults)
288309

289310
mockFetch.mockResolvedValue({
290311
ok: true,
@@ -348,7 +369,7 @@ describe('Knowledge Search API Route', () => {
348369
embeddingModel: 'text-embedding-3-small',
349370
},
350371
})
351-
mockHandleVectorOnlySearch.mockResolvedValue(mockSearchResults)
372+
mockExecuteKnowledgeSearch.mockResolvedValue(mockSearchResults)
352373
const attribution = encodeURIComponent(
353374
JSON.stringify({
354375
actorUserId: 'user-123',
@@ -532,7 +553,7 @@ describe('Knowledge Search API Route', () => {
532553
mockGetUserId.mockResolvedValue('user-123')
533554
dbChainMockFns.limit.mockResolvedValueOnce(mockKnowledgeBases)
534555

535-
mockHandleVectorOnlySearch.mockRejectedValueOnce(new Error('Database error'))
556+
mockExecuteKnowledgeSearch.mockRejectedValueOnce(new Error('Database error'))
536557

537558
const req = createMockRequest('POST', validSearchData)
538559
const response = await POST(req)
@@ -750,7 +771,7 @@ describe('Knowledge Search API Route', () => {
750771

751772
dbChainMockFns.limit.mockResolvedValueOnce(mockTagDefinitions)
752773

753-
mockHandleTagOnlySearch.mockResolvedValue(mockTaggedResults)
774+
mockExecuteKnowledgeSearch.mockResolvedValue(mockTaggedResults)
754775

755776
const req = createMockRequest('POST', tagOnlyData)
756777
const response = await POST(req)
@@ -763,9 +784,10 @@ describe('Knowledge Search API Route', () => {
763784
expect(data.data.query).toBe('') // Empty query
764785
expect(data.data.cost).toBeUndefined() // No cost for tag-only search
765786
expect(mockGenerateSearchEmbedding).not.toHaveBeenCalled() // No embedding API call
766-
expect(mockHandleTagOnlySearch).toHaveBeenCalledWith({
787+
expect(mockExecuteKnowledgeSearch).toHaveBeenCalledWith({
767788
knowledgeBaseIds: ['kb-123'],
768789
topK: 10,
790+
searchMode: 'vector',
769791
structuredFilters: [
770792
{ tagSlot: 'tag1', fieldType: 'text', operator: 'eq', value: 'api', valueTo: undefined },
771793
],
@@ -796,7 +818,7 @@ describe('Knowledge Search API Route', () => {
796818

797819
dbChainMockFns.limit.mockResolvedValueOnce(mockTagDefinitions)
798820

799-
mockHandleTagAndVectorSearch.mockResolvedValue(mockSearchResults)
821+
mockExecuteKnowledgeSearch.mockResolvedValue(mockSearchResults)
800822

801823
mockFetch.mockResolvedValue({
802824
ok: true,
@@ -816,14 +838,15 @@ describe('Knowledge Search API Route', () => {
816838
expect(data.data.query).toBe('test search')
817839
expect(data.data.cost).toBeDefined() // Cost included for vector search
818840
expect(mockGenerateSearchEmbedding).toHaveBeenCalled() // Embedding API called
819-
expect(mockHandleTagAndVectorSearch).toHaveBeenCalledWith({
841+
expect(mockExecuteKnowledgeSearch).toHaveBeenCalledWith({
820842
knowledgeBaseIds: ['kb-123'],
821843
topK: 10,
844+
searchMode: 'vector',
845+
query: 'test search',
846+
queryVector: JSON.stringify(mockEmbedding),
822847
structuredFilters: [
823848
{ tagSlot: 'tag1', fieldType: 'text', operator: 'eq', value: 'api', valueTo: undefined },
824849
],
825-
queryVector: JSON.stringify(mockEmbedding),
826-
distanceThreshold: 1, // Single KB uses threshold of 1.0
827850
})
828851
})
829852

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

9881011
mockGetDocumentTagDefinitions.mockResolvedValue(mockTagDefinitions)
9891012

990-
mockHandleTagOnlySearch.mockResolvedValue(mockTaggedResults)
1013+
mockExecuteKnowledgeSearch.mockResolvedValue(mockTaggedResults)
9911014

9921015
dbChainMockFns.limit.mockResolvedValueOnce(mockTagDefinitions)
9931016

@@ -1016,7 +1039,7 @@ describe('Knowledge Search API Route', () => {
10161039
},
10171040
})
10181041

1019-
mockHandleVectorOnlySearch.mockResolvedValue([
1042+
mockExecuteKnowledgeSearch.mockResolvedValue([
10201043
{
10211044
id: 'chunk-1',
10221045
content: 'Content from active document',
@@ -1034,13 +1057,6 @@ describe('Knowledge Search API Route', () => {
10341057
},
10351058
])
10361059

1037-
mockGetQueryStrategy.mockReturnValue({
1038-
useParallel: false,
1039-
distanceThreshold: 1.0,
1040-
parallelLimit: 15,
1041-
singleQueryOptimized: true,
1042-
})
1043-
10441060
mockGenerateSearchEmbedding.mockResolvedValue({ embedding: [0.1, 0.2, 0.3], isBYOK: false })
10451061
mockGetDocumentMetadataByIds.mockResolvedValue({
10461062
'doc-active': {
@@ -1092,7 +1108,7 @@ describe('Knowledge Search API Route', () => {
10921108
{ tagSlot: 'tag1', displayName: 'tag1', fieldType: 'text' },
10931109
])
10941110

1095-
mockHandleTagOnlySearch.mockResolvedValue([
1111+
mockExecuteKnowledgeSearch.mockResolvedValue([
10961112
{
10971113
id: 'chunk-2',
10981114
content: 'Content from active document with tag',
@@ -1110,13 +1126,6 @@ describe('Knowledge Search API Route', () => {
11101126
},
11111127
])
11121128

1113-
mockGetQueryStrategy.mockReturnValue({
1114-
useParallel: false,
1115-
distanceThreshold: 1.0,
1116-
parallelLimit: 15,
1117-
singleQueryOptimized: true,
1118-
})
1119-
11201129
mockGetDocumentMetadataByIds.mockResolvedValue({
11211130
'doc-active-tagged': { filename: 'Active Tagged Document.pdf', sourceUrl: null },
11221131
})
@@ -1164,7 +1173,7 @@ describe('Knowledge Search API Route', () => {
11641173
{ tagSlot: 'tag1', displayName: 'tag1', fieldType: 'text' },
11651174
])
11661175

1167-
mockHandleTagAndVectorSearch.mockResolvedValue([
1176+
mockExecuteKnowledgeSearch.mockResolvedValue([
11681177
{
11691178
id: 'chunk-3',
11701179
content: 'Relevant content from active document',
@@ -1182,13 +1191,6 @@ describe('Knowledge Search API Route', () => {
11821191
},
11831192
])
11841193

1185-
mockGetQueryStrategy.mockReturnValue({
1186-
useParallel: false,
1187-
distanceThreshold: 1.0,
1188-
parallelLimit: 15,
1189-
singleQueryOptimized: true,
1190-
})
1191-
11921194
mockGenerateSearchEmbedding.mockResolvedValue({ embedding: [0.1, 0.2, 0.3], isBYOK: false })
11931195
mockGetDocumentMetadataByIds.mockResolvedValue({
11941196
'doc-active-combined': { filename: 'Active Combined Search.pdf', sourceUrl: null },

0 commit comments

Comments
 (0)