@convex-dev/rag 0.7.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/hybridRank.d.ts +1 -1
- package/dist/client/hybridRank.js +1 -1
- package/dist/client/index.d.ts +35 -3
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +32 -16
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/component.d.ts +6 -1
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/_generated/server.d.ts.map +1 -1
- package/dist/component/chunks.d.ts +9 -2
- package/dist/component/chunks.d.ts.map +1 -1
- package/dist/component/chunks.js +66 -63
- package/dist/component/chunks.js.map +1 -1
- package/dist/component/schema.d.ts +34 -34
- package/dist/component/schema.d.ts.map +1 -1
- package/dist/component/schema.js +0 -1
- package/dist/component/schema.js.map +1 -1
- package/dist/component/search.d.ts +44 -1
- package/dist/component/search.d.ts.map +1 -1
- package/dist/component/search.js +188 -17
- package/dist/component/search.js.map +1 -1
- package/dist/shared.d.ts +2 -0
- package/dist/shared.d.ts.map +1 -1
- package/dist/shared.js +1 -0
- package/dist/shared.js.map +1 -1
- package/package.json +30 -28
- package/src/client/hybridRank.ts +1 -1
- package/src/client/index.ts +76 -16
- package/src/component/_generated/component.ts +6 -1
- package/src/component/_generated/server.ts +0 -5
- package/src/component/chunks.ts +102 -92
- package/src/component/schema.ts +0 -1
- package/src/component/search.test.ts +303 -1
- package/src/component/search.ts +266 -19
- package/src/shared.ts +7 -0
package/src/component/search.ts
CHANGED
|
@@ -1,20 +1,30 @@
|
|
|
1
1
|
import { v, type Infer } from "convex/values";
|
|
2
|
-
import { action } from "./_generated/server.js";
|
|
2
|
+
import { action, internalQuery, type QueryCtx } from "./_generated/server.js";
|
|
3
3
|
import { searchEmbeddings } from "./embeddings/index.js";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
filterFieldsFromNumbers,
|
|
6
|
+
numberedFiltersFromNamedFilters,
|
|
7
|
+
vNamedFilter,
|
|
8
|
+
type NumberedFilter,
|
|
9
|
+
} from "./filters.js";
|
|
5
10
|
import { internal } from "./_generated/api.js";
|
|
6
11
|
import {
|
|
7
12
|
vEntry,
|
|
8
13
|
vSearchResult,
|
|
14
|
+
vSearchType,
|
|
9
15
|
type SearchResult,
|
|
10
16
|
type EntryId,
|
|
11
17
|
} from "../shared.js";
|
|
12
|
-
import type {
|
|
18
|
+
import type { Doc, Id } from "./_generated/dataModel.js";
|
|
19
|
+
import { buildRanges, type vRangeResult } from "./chunks.js";
|
|
20
|
+
import { hybridRank } from "../client/hybridRank.js";
|
|
21
|
+
import { vVectorId, type VectorTableId } from "./embeddings/tables.js";
|
|
13
22
|
|
|
14
23
|
export const search = action({
|
|
15
24
|
args: {
|
|
16
25
|
namespace: v.string(),
|
|
17
|
-
embedding: v.array(v.number()),
|
|
26
|
+
embedding: v.optional(v.array(v.number())),
|
|
27
|
+
dimension: v.optional(v.number()),
|
|
18
28
|
modelId: v.string(),
|
|
19
29
|
// These are all OR'd together
|
|
20
30
|
filters: v.array(vNamedFilter),
|
|
@@ -23,6 +33,10 @@ export const search = action({
|
|
|
23
33
|
chunkContext: v.optional(
|
|
24
34
|
v.object({ before: v.number(), after: v.number() }),
|
|
25
35
|
),
|
|
36
|
+
searchType: v.optional(vSearchType),
|
|
37
|
+
textQuery: v.optional(v.string()),
|
|
38
|
+
textWeight: v.optional(v.number()),
|
|
39
|
+
vectorWeight: v.optional(v.number()),
|
|
26
40
|
},
|
|
27
41
|
returns: v.object({
|
|
28
42
|
results: v.array(vSearchResult),
|
|
@@ -36,51 +50,284 @@ export const search = action({
|
|
|
36
50
|
entries: Infer<typeof vEntry>[];
|
|
37
51
|
}> => {
|
|
38
52
|
const { modelId, embedding, filters, limit } = args;
|
|
53
|
+
const dimension = embedding?.length ?? args.dimension;
|
|
54
|
+
if (!dimension) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
"Either embedding or dimension must be provided to search.",
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
39
60
|
const namespace = await ctx.runQuery(
|
|
40
61
|
internal.namespaces.getCompatibleNamespace,
|
|
41
62
|
{
|
|
42
63
|
namespace: args.namespace,
|
|
43
64
|
modelId,
|
|
44
|
-
dimension
|
|
65
|
+
dimension,
|
|
45
66
|
filterNames: filters.map((f) => f.name),
|
|
46
67
|
},
|
|
47
68
|
);
|
|
48
69
|
if (!namespace) {
|
|
49
70
|
console.debug(
|
|
50
|
-
`No compatible namespace found for ${args.namespace} with model ${args.modelId} and dimension ${
|
|
71
|
+
`No compatible namespace found for ${args.namespace} with model ${args.modelId} and dimension ${dimension} and filters ${filters.map((f) => f.name).join(", ")}.`,
|
|
51
72
|
);
|
|
52
73
|
return {
|
|
53
74
|
results: [],
|
|
54
75
|
entries: [],
|
|
55
76
|
};
|
|
56
77
|
}
|
|
57
|
-
const results = await searchEmbeddings(ctx, {
|
|
58
|
-
embedding,
|
|
59
|
-
namespaceId: namespace._id,
|
|
60
|
-
filters: numberedFiltersFromNamedFilters(filters, namespace.filterNames),
|
|
61
|
-
limit,
|
|
62
|
-
});
|
|
63
78
|
|
|
64
|
-
const threshold = args.vectorScoreThreshold ?? -1;
|
|
65
|
-
const aboveThreshold = results.filter((r) => r._score >= threshold);
|
|
66
79
|
const chunkContext = args.chunkContext ?? { before: 0, after: 0 };
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
80
|
+
const numberedFilters = numberedFiltersFromNamedFilters(
|
|
81
|
+
filters,
|
|
82
|
+
namespace.filterNames,
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const hasEmbedding = !!embedding;
|
|
86
|
+
const hasTextQuery = !!args.textQuery;
|
|
87
|
+
|
|
88
|
+
// Vector-only path: return results with cosine similarity scores.
|
|
89
|
+
if (hasEmbedding && !hasTextQuery) {
|
|
90
|
+
const vectorResults = await searchEmbeddings(ctx, {
|
|
91
|
+
embedding,
|
|
92
|
+
namespaceId: namespace._id,
|
|
93
|
+
filters: numberedFilters,
|
|
94
|
+
limit,
|
|
95
|
+
});
|
|
96
|
+
const threshold = args.vectorScoreThreshold ?? -1;
|
|
97
|
+
const aboveThreshold = vectorResults.filter((r) => r._score >= threshold);
|
|
98
|
+
// TODO: break this up if there are too many results
|
|
99
|
+
const { ranges, entries } = await ctx.runQuery(
|
|
100
|
+
internal.chunks.getRangesOfChunks,
|
|
101
|
+
{
|
|
102
|
+
embeddingIds: aboveThreshold.map((r) => r._id),
|
|
103
|
+
chunkContext,
|
|
104
|
+
},
|
|
105
|
+
);
|
|
106
|
+
return {
|
|
107
|
+
results: ranges
|
|
108
|
+
.map((r, i) => publicSearchResult(r, aboveThreshold[i]._score))
|
|
109
|
+
.filter((r) => r !== null),
|
|
110
|
+
entries: entries as Infer<typeof vEntry>[],
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Hybrid or text-only path: combine vector and text results with RRF.
|
|
115
|
+
let embeddingIds: VectorTableId[] = [];
|
|
116
|
+
if (hasEmbedding) {
|
|
117
|
+
const vectorResults = await searchEmbeddings(ctx, {
|
|
118
|
+
embedding: embedding!,
|
|
119
|
+
namespaceId: namespace._id,
|
|
120
|
+
filters: numberedFilters,
|
|
121
|
+
limit,
|
|
122
|
+
});
|
|
123
|
+
const threshold = args.vectorScoreThreshold ?? -1;
|
|
124
|
+
embeddingIds = vectorResults
|
|
125
|
+
.filter((r) => r._score >= threshold)
|
|
126
|
+
.map((r) => r._id);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (!hasTextQuery) {
|
|
130
|
+
throw new Error(
|
|
131
|
+
"Search requires at least one of embedding or textQuery.",
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const { ranges, entries, resultCount } = await ctx.runQuery(
|
|
136
|
+
internal.search.textAndRanges,
|
|
70
137
|
{
|
|
71
|
-
embeddingIds
|
|
138
|
+
embeddingIds,
|
|
139
|
+
textQuery: args.textQuery!,
|
|
140
|
+
namespaceId: namespace._id,
|
|
141
|
+
filters: numberedFilters,
|
|
142
|
+
limit,
|
|
143
|
+
vectorWeight: args.vectorWeight ?? 1,
|
|
144
|
+
textWeight: args.textWeight ?? 1,
|
|
72
145
|
chunkContext,
|
|
73
146
|
},
|
|
74
147
|
);
|
|
148
|
+
|
|
149
|
+
// Position-based scores (1.0 for first, decreasing linearly).
|
|
75
150
|
return {
|
|
76
151
|
results: ranges
|
|
77
|
-
.map((r, i) => publicSearchResult(r,
|
|
152
|
+
.map((r, i) => publicSearchResult(r, (resultCount - i) / resultCount))
|
|
78
153
|
.filter((r) => r !== null),
|
|
79
154
|
entries: entries as Infer<typeof vEntry>[],
|
|
80
155
|
};
|
|
81
156
|
},
|
|
82
157
|
});
|
|
83
158
|
|
|
159
|
+
type TextSearchResult = {
|
|
160
|
+
chunkId: Id<"chunks">;
|
|
161
|
+
entryId: Id<"entries">;
|
|
162
|
+
order: number;
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
async function textSearchImpl(
|
|
166
|
+
ctx: QueryCtx,
|
|
167
|
+
args: {
|
|
168
|
+
query: string;
|
|
169
|
+
namespaceId: Id<"namespaces">;
|
|
170
|
+
filters: NumberedFilter[];
|
|
171
|
+
limit: number;
|
|
172
|
+
},
|
|
173
|
+
): Promise<TextSearchResult[]> {
|
|
174
|
+
const toResults = (chunks: Doc<"chunks">[]): TextSearchResult[] =>
|
|
175
|
+
chunks
|
|
176
|
+
.filter((chunk) => chunk.state.kind === "ready")
|
|
177
|
+
.map((chunk) => ({
|
|
178
|
+
chunkId: chunk._id,
|
|
179
|
+
entryId: chunk.entryId,
|
|
180
|
+
order: chunk.order,
|
|
181
|
+
}));
|
|
182
|
+
|
|
183
|
+
// No user filters — just filter by namespaceId.
|
|
184
|
+
if (args.filters.length === 0) {
|
|
185
|
+
const results = await ctx.db
|
|
186
|
+
.query("chunks")
|
|
187
|
+
.withSearchIndex("searchableText", (q) =>
|
|
188
|
+
q
|
|
189
|
+
.search("state.searchableText", args.query)
|
|
190
|
+
.eq("namespaceId", args.namespaceId),
|
|
191
|
+
)
|
|
192
|
+
.take(args.limit);
|
|
193
|
+
return toResults(results);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// OR across filter conditions: run one text search per filter and dedupe.
|
|
197
|
+
const seen = new Set<Id<"chunks">>();
|
|
198
|
+
const merged: TextSearchResult[] = [];
|
|
199
|
+
for (const filter of args.filters) {
|
|
200
|
+
const fields = filterFieldsFromNumbers(args.namespaceId, filter);
|
|
201
|
+
const results = await ctx.db
|
|
202
|
+
.query("chunks")
|
|
203
|
+
.withSearchIndex("searchableText", (q) => {
|
|
204
|
+
let query = q
|
|
205
|
+
.search("state.searchableText", args.query)
|
|
206
|
+
.eq("namespaceId", args.namespaceId);
|
|
207
|
+
for (const [field, value] of Object.entries(fields)) {
|
|
208
|
+
query = query.eq(
|
|
209
|
+
field as "filter0" | "filter1" | "filter2" | "filter3",
|
|
210
|
+
value,
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
return query;
|
|
214
|
+
})
|
|
215
|
+
.take(args.limit);
|
|
216
|
+
for (const r of toResults(results)) {
|
|
217
|
+
if (!seen.has(r.chunkId)) {
|
|
218
|
+
seen.add(r.chunkId);
|
|
219
|
+
merged.push(r);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return merged.slice(0, args.limit);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export const textSearch = internalQuery({
|
|
227
|
+
args: {
|
|
228
|
+
query: v.string(),
|
|
229
|
+
namespaceId: v.id("namespaces"),
|
|
230
|
+
// Numbered filters, OR'd together (same semantics as vector search).
|
|
231
|
+
filters: v.array(v.any()),
|
|
232
|
+
limit: v.number(),
|
|
233
|
+
},
|
|
234
|
+
returns: v.array(
|
|
235
|
+
v.object({
|
|
236
|
+
chunkId: v.id("chunks"),
|
|
237
|
+
entryId: v.id("entries"),
|
|
238
|
+
order: v.number(),
|
|
239
|
+
}),
|
|
240
|
+
),
|
|
241
|
+
handler: async (ctx, args) => {
|
|
242
|
+
return textSearchImpl(ctx, {
|
|
243
|
+
query: args.query,
|
|
244
|
+
namespaceId: args.namespaceId,
|
|
245
|
+
filters: args.filters as NumberedFilter[],
|
|
246
|
+
limit: args.limit,
|
|
247
|
+
});
|
|
248
|
+
},
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
export const textAndRanges = internalQuery({
|
|
252
|
+
args: {
|
|
253
|
+
embeddingIds: v.array(vVectorId),
|
|
254
|
+
textQuery: v.string(),
|
|
255
|
+
namespaceId: v.id("namespaces"),
|
|
256
|
+
filters: v.array(v.any()),
|
|
257
|
+
limit: v.number(),
|
|
258
|
+
vectorWeight: v.number(),
|
|
259
|
+
textWeight: v.number(),
|
|
260
|
+
chunkContext: v.object({ before: v.number(), after: v.number() }),
|
|
261
|
+
},
|
|
262
|
+
returns: v.object({
|
|
263
|
+
ranges: v.array(
|
|
264
|
+
v.union(
|
|
265
|
+
v.null(),
|
|
266
|
+
v.object({
|
|
267
|
+
entryId: v.id("entries"),
|
|
268
|
+
order: v.number(),
|
|
269
|
+
startOrder: v.number(),
|
|
270
|
+
content: v.array(
|
|
271
|
+
v.object({
|
|
272
|
+
text: v.string(),
|
|
273
|
+
metadata: v.optional(v.record(v.string(), v.any())),
|
|
274
|
+
}),
|
|
275
|
+
),
|
|
276
|
+
}),
|
|
277
|
+
),
|
|
278
|
+
),
|
|
279
|
+
entries: v.array(vEntry),
|
|
280
|
+
resultCount: v.number(),
|
|
281
|
+
}),
|
|
282
|
+
handler: async (ctx, args) => {
|
|
283
|
+
// 1. Map embedding IDs to chunk IDs.
|
|
284
|
+
const vectorChunkIds: Id<"chunks">[] = (
|
|
285
|
+
await Promise.all(
|
|
286
|
+
args.embeddingIds.map(async (embeddingId) => {
|
|
287
|
+
const chunk = await ctx.db
|
|
288
|
+
.query("chunks")
|
|
289
|
+
.withIndex("embeddingId", (q) =>
|
|
290
|
+
q.eq("state.embeddingId", embeddingId),
|
|
291
|
+
)
|
|
292
|
+
.order("desc")
|
|
293
|
+
.first();
|
|
294
|
+
return chunk?._id ?? null;
|
|
295
|
+
}),
|
|
296
|
+
)
|
|
297
|
+
).filter((id) => id !== null);
|
|
298
|
+
|
|
299
|
+
// 2. Run text search.
|
|
300
|
+
const textResults = await textSearchImpl(ctx, {
|
|
301
|
+
query: args.textQuery,
|
|
302
|
+
namespaceId: args.namespaceId,
|
|
303
|
+
filters: args.filters as NumberedFilter[],
|
|
304
|
+
limit: args.limit,
|
|
305
|
+
});
|
|
306
|
+
const textChunkIds: Id<"chunks">[] = textResults.map((r) => r.chunkId);
|
|
307
|
+
|
|
308
|
+
// 3. Merge using Reciprocal Rank Fusion.
|
|
309
|
+
const mergedChunkIds = hybridRank<Id<"chunks">>(
|
|
310
|
+
[vectorChunkIds, textChunkIds],
|
|
311
|
+
{ k: 10, weights: [args.vectorWeight, args.textWeight] },
|
|
312
|
+
).slice(0, args.limit);
|
|
313
|
+
|
|
314
|
+
if (mergedChunkIds.length === 0) {
|
|
315
|
+
return { ranges: [], entries: [], resultCount: 0 };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// 4. Build ranges from merged chunk IDs.
|
|
319
|
+
const chunks = await Promise.all(
|
|
320
|
+
mergedChunkIds.map((id) => ctx.db.get(id)),
|
|
321
|
+
);
|
|
322
|
+
const { ranges, entries } = await buildRanges(
|
|
323
|
+
ctx,
|
|
324
|
+
chunks,
|
|
325
|
+
args.chunkContext,
|
|
326
|
+
);
|
|
327
|
+
return { ranges, entries, resultCount: mergedChunkIds.length };
|
|
328
|
+
},
|
|
329
|
+
});
|
|
330
|
+
|
|
84
331
|
function publicSearchResult(
|
|
85
332
|
r: Infer<typeof vRangeResult> | null,
|
|
86
333
|
score: number,
|
package/src/shared.ts
CHANGED
|
@@ -35,6 +35,13 @@ export const vSearchResult = v.object({
|
|
|
35
35
|
|
|
36
36
|
export type SearchResult = Infer<typeof vSearchResult>;
|
|
37
37
|
|
|
38
|
+
export const vSearchType = v.union(
|
|
39
|
+
v.literal("vector"),
|
|
40
|
+
v.literal("text"),
|
|
41
|
+
v.literal("hybrid"),
|
|
42
|
+
);
|
|
43
|
+
export type SearchType = Infer<typeof vSearchType>;
|
|
44
|
+
|
|
38
45
|
export const vStatus = v.union(
|
|
39
46
|
v.literal("pending"),
|
|
40
47
|
v.literal("ready"),
|