-
Notifications
You must be signed in to change notification settings - Fork 3.5k
Expand file tree
/
Copy pathroute.ts
More file actions
395 lines (355 loc) · 14.4 KB
/
route.ts
File metadata and controls
395 lines (355 loc) · 14.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { generateRequestId } from '@/lib/core/utils/request'
import { ALL_TAG_SLOTS } from '@/lib/knowledge/constants'
import { getDocumentTagDefinitions } from '@/lib/knowledge/tags/service'
import { createLogger } from '@/lib/logs/console/logger'
import { estimateTokenCount } from '@/lib/tokenization/estimators'
import { getUserId } from '@/app/api/auth/oauth/utils'
import {
generateSearchEmbedding,
getDocumentNamesByIds,
getQueryStrategy,
handleTagAndVectorSearch,
handleTagOnlySearch,
handleVectorOnlySearch,
type SearchResult,
} from '@/app/api/knowledge/search/utils'
import { checkKnowledgeBaseAccess } from '@/app/api/knowledge/utils'
import { calculateCost } from '@/providers/utils'
const logger = createLogger('VectorSearchAPI')
/** Structured tag filter with operator support */
const StructuredTagFilterSchema = z.object({
tagName: z.string(),
tagSlot: z.string().optional(),
fieldType: z.enum(['text', 'number', 'date', 'boolean']).default('text'),
operator: z.string().default('eq'),
value: z.union([z.string(), z.number(), z.boolean()]),
valueTo: z.union([z.string(), z.number()]).optional(),
})
const VectorSearchSchema = z
.object({
knowledgeBaseIds: z.union([
z.string().min(1, 'Knowledge base ID is required'),
z.array(z.string().min(1)).min(1, 'At least one knowledge base ID is required'),
]),
query: z
.string()
.optional()
.nullable()
.transform((val) => val || undefined),
topK: z
.number()
.min(1)
.max(100)
.optional()
.nullable()
.default(10)
.transform((val) => val ?? 10),
filters: z
.record(z.string())
.optional()
.nullable()
.transform((val) => val || undefined), // Legacy format: simple key-value pairs
tagFilters: z
.array(StructuredTagFilterSchema)
.optional()
.nullable()
.transform((val) => val || undefined), // New format: structured filters with operators
})
.refine(
(data) => {
// Ensure at least query or filters are provided
const hasQuery = data.query && data.query.trim().length > 0
const hasLegacyFilters = data.filters && Object.keys(data.filters).length > 0
const hasTagFilters = data.tagFilters && data.tagFilters.length > 0
return hasQuery || hasLegacyFilters || hasTagFilters
},
{
message: 'Please provide either a search query or tag filters to search your knowledge base',
}
)
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const body = await request.json()
const { workflowId, ...searchParams } = body
const userId = await getUserId(requestId, workflowId)
if (!userId) {
const errorMessage = workflowId ? 'Workflow not found' : 'Unauthorized'
const statusCode = workflowId ? 404 : 401
return NextResponse.json({ error: errorMessage }, { status: statusCode })
}
try {
const validatedData = VectorSearchSchema.parse(searchParams)
const knowledgeBaseIds = Array.isArray(validatedData.knowledgeBaseIds)
? validatedData.knowledgeBaseIds
: [validatedData.knowledgeBaseIds]
// Check access permissions in parallel for performance
const accessChecks = await Promise.all(
knowledgeBaseIds.map((kbId) => checkKnowledgeBaseAccess(kbId, userId))
)
const accessibleKbIds: string[] = knowledgeBaseIds.filter(
(_, idx) => accessChecks[idx]?.hasAccess
)
// Map display names to tag slots for filtering
let mappedFilters: Record<string, string> = {}
let structuredFilters: Array<{
tagSlot: string
fieldType: string
operator: string
value: string | number | boolean
valueTo?: string | number
}> = []
// Handle new structured tagFilters format
if (validatedData.tagFilters && accessibleKbIds.length > 0) {
try {
const kbId = accessibleKbIds[0]
const tagDefs = await getDocumentTagDefinitions(kbId)
// Create mapping from display name to tag slot and fieldType
const displayNameToTagDef: Record<string, { tagSlot: string; fieldType: string }> = {}
tagDefs.forEach((def) => {
displayNameToTagDef[def.displayName] = {
tagSlot: def.tagSlot,
fieldType: def.fieldType,
}
})
structuredFilters = validatedData.tagFilters.map((filter) => {
const tagDef = displayNameToTagDef[filter.tagName]
const tagSlot = filter.tagSlot || tagDef?.tagSlot || filter.tagName
const fieldType = filter.fieldType || tagDef?.fieldType || 'text'
logger.debug(
`[${requestId}] Structured filter: ${filter.tagName} -> ${tagSlot} (${fieldType}) ${filter.operator} ${filter.value}`
)
return {
tagSlot,
fieldType,
operator: filter.operator,
value: filter.value,
valueTo: filter.valueTo,
}
})
logger.debug(`[${requestId}] Processed ${structuredFilters.length} structured filters`)
} catch (error) {
logger.error(`[${requestId}] Structured filter processing error:`, error)
}
}
// Handle legacy filters format (for backwards compatibility)
if (validatedData.filters && accessibleKbIds.length > 0) {
try {
// Fetch tag definitions for the first accessible KB (since we're using single KB now)
const kbId = accessibleKbIds[0]
const tagDefs = await getDocumentTagDefinitions(kbId)
logger.debug(`[${requestId}] Found tag definitions:`, tagDefs)
logger.debug(`[${requestId}] Original filters:`, validatedData.filters)
// Create mapping from display name to tag slot
const displayNameToSlot: Record<string, string> = {}
tagDefs.forEach((def) => {
displayNameToSlot[def.displayName] = def.tagSlot
})
// Map the filters and handle OR logic
Object.entries(validatedData.filters).forEach(([key, value]) => {
if (value) {
const tagSlot = displayNameToSlot[key] || key // Fallback to key if no mapping found
// Check if this is an OR filter (contains |OR| separator)
if (value.includes('|OR|')) {
logger.debug(
`[${requestId}] OR filter detected: "${key}" -> "${tagSlot}" = "${value}"`
)
}
mappedFilters[tagSlot] = value
logger.debug(`[${requestId}] Mapped filter: "${key}" -> "${tagSlot}" = "${value}"`)
}
})
logger.debug(`[${requestId}] Final mapped filters:`, mappedFilters)
} catch (error) {
logger.error(`[${requestId}] Filter mapping error:`, error)
// If mapping fails, use original filters
mappedFilters = validatedData.filters
}
}
if (accessibleKbIds.length === 0) {
return NextResponse.json(
{ error: 'Knowledge base not found or access denied' },
{ status: 404 }
)
}
// Generate query embedding only if query is provided
const hasQuery = validatedData.query && validatedData.query.trim().length > 0
// Start embedding generation early and await when needed
const queryEmbeddingPromise = hasQuery
? generateSearchEmbedding(validatedData.query!)
: Promise.resolve(null)
// Check if any requested knowledge bases were not accessible
const inaccessibleKbIds = knowledgeBaseIds.filter((id) => !accessibleKbIds.includes(id))
if (inaccessibleKbIds.length > 0) {
return NextResponse.json(
{ error: `Knowledge bases not found or access denied: ${inaccessibleKbIds.join(', ')}` },
{ status: 404 }
)
}
let results: SearchResult[]
const hasLegacyFilters = mappedFilters && Object.keys(mappedFilters).length > 0
const hasStructuredFilters = structuredFilters && structuredFilters.length > 0
const hasFilters = hasLegacyFilters || hasStructuredFilters
if (!hasQuery && hasFilters) {
// Tag-only search without vector similarity
logger.debug(
`[${requestId}] Executing tag-only search with filters:`,
hasStructuredFilters ? structuredFilters : mappedFilters
)
results = await handleTagOnlySearch({
knowledgeBaseIds: accessibleKbIds,
topK: validatedData.topK,
filters: hasLegacyFilters ? mappedFilters : undefined,
structuredFilters: hasStructuredFilters ? structuredFilters : undefined,
})
} else if (hasQuery && hasFilters) {
// Tag + Vector search
logger.debug(
`[${requestId}] Executing tag + vector search with filters:`,
hasStructuredFilters ? structuredFilters : mappedFilters
)
const strategy = getQueryStrategy(accessibleKbIds.length, validatedData.topK)
const queryVector = JSON.stringify(await queryEmbeddingPromise)
results = await handleTagAndVectorSearch({
knowledgeBaseIds: accessibleKbIds,
topK: validatedData.topK,
filters: hasLegacyFilters ? mappedFilters : undefined,
structuredFilters: hasStructuredFilters ? structuredFilters : undefined,
queryVector,
distanceThreshold: strategy.distanceThreshold,
})
} else if (hasQuery && !hasFilters) {
// Vector-only search
logger.debug(`[${requestId}] Executing vector-only search`)
const strategy = getQueryStrategy(accessibleKbIds.length, validatedData.topK)
const queryVector = JSON.stringify(await queryEmbeddingPromise)
results = await handleVectorOnlySearch({
knowledgeBaseIds: accessibleKbIds,
topK: validatedData.topK,
queryVector,
distanceThreshold: strategy.distanceThreshold,
})
} else {
// This should never happen due to schema validation, but just in case
return NextResponse.json(
{
error:
'Please provide either a search query or tag filters to search your knowledge base',
},
{ status: 400 }
)
}
// Calculate cost for the embedding (with fallback if calculation fails)
let cost = null
let tokenCount = null
if (hasQuery) {
try {
tokenCount = estimateTokenCount(validatedData.query!, 'openai')
cost = calculateCost('text-embedding-3-small', tokenCount.count, 0, false)
} catch (error) {
logger.warn(`[${requestId}] Failed to calculate cost for search query`, {
error: error instanceof Error ? error.message : 'Unknown error',
})
// Continue without cost information rather than failing the search
}
}
// Fetch tag definitions for display name mapping (reuse the same fetch from filtering)
const tagDefsResults = await Promise.all(
accessibleKbIds.map(async (kbId) => {
try {
const tagDefs = await getDocumentTagDefinitions(kbId)
const map: Record<string, string> = {}
tagDefs.forEach((def) => {
map[def.tagSlot] = def.displayName
})
return { kbId, map }
} catch (error) {
logger.warn(
`[${requestId}] Failed to fetch tag definitions for display mapping:`,
error
)
return { kbId, map: {} as Record<string, string> }
}
})
)
const tagDefinitionsMap: Record<string, Record<string, string>> = {}
tagDefsResults.forEach(({ kbId, map }) => {
tagDefinitionsMap[kbId] = map
})
// Fetch document names for the results
const documentIds = results.map((result) => result.documentId)
const documentNameMap = await getDocumentNamesByIds(documentIds)
return NextResponse.json({
success: true,
data: {
results: results.map((result) => {
const kbTagMap = tagDefinitionsMap[result.knowledgeBaseId] || {}
logger.debug(
`[${requestId}] Result KB: ${result.knowledgeBaseId}, available mappings:`,
kbTagMap
)
// Create tags object with display names
const tags: Record<string, any> = {}
ALL_TAG_SLOTS.forEach((slot) => {
const tagValue = (result as any)[slot]
if (tagValue !== null && tagValue !== undefined) {
const displayName = kbTagMap[slot] || slot
logger.debug(
`[${requestId}] Mapping ${slot}="${tagValue}" -> "${displayName}"="${tagValue}"`
)
tags[displayName] = tagValue
}
})
return {
documentId: result.documentId,
documentName: documentNameMap[result.documentId] || undefined,
content: result.content,
chunkIndex: result.chunkIndex,
metadata: tags, // Clean display name mapped tags
similarity: hasQuery ? 1 - result.distance : 1, // Perfect similarity for tag-only searches
}
}),
query: validatedData.query || '',
knowledgeBaseIds: accessibleKbIds,
knowledgeBaseId: accessibleKbIds[0],
topK: validatedData.topK,
totalResults: results.length,
...(cost && tokenCount
? {
cost: {
input: cost.input,
output: cost.output,
total: cost.total,
tokens: {
prompt: tokenCount.count,
completion: 0,
total: tokenCount.count,
},
model: 'text-embedding-3-small',
pricing: cost.pricing,
},
}
: {}),
},
})
} catch (validationError) {
if (validationError instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Invalid request data', details: validationError.errors },
{ status: 400 }
)
}
throw validationError
}
} catch (error) {
return NextResponse.json(
{
error: 'Failed to perform vector search',
message: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
)
}
}