@convex-dev/rag 0.6.1 → 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.
Files changed (41) hide show
  1. package/README.md +4 -17
  2. package/dist/client/hybridRank.d.ts +1 -1
  3. package/dist/client/hybridRank.js +1 -1
  4. package/dist/client/index.d.ts +39 -7
  5. package/dist/client/index.d.ts.map +1 -1
  6. package/dist/client/index.js +33 -14
  7. package/dist/client/index.js.map +1 -1
  8. package/dist/component/_generated/component.d.ts +6 -1
  9. package/dist/component/_generated/component.d.ts.map +1 -1
  10. package/dist/component/_generated/dataModel.d.ts +1 -1
  11. package/dist/component/_generated/server.d.ts.map +1 -1
  12. package/dist/component/chunks.d.ts +9 -2
  13. package/dist/component/chunks.d.ts.map +1 -1
  14. package/dist/component/chunks.js +66 -63
  15. package/dist/component/chunks.js.map +1 -1
  16. package/dist/component/embeddings/tables.d.ts +2 -2
  17. package/dist/component/embeddings/tables.d.ts.map +1 -1
  18. package/dist/component/schema.d.ts +87 -84
  19. package/dist/component/schema.d.ts.map +1 -1
  20. package/dist/component/schema.js +0 -1
  21. package/dist/component/schema.js.map +1 -1
  22. package/dist/component/search.d.ts +44 -1
  23. package/dist/component/search.d.ts.map +1 -1
  24. package/dist/component/search.js +188 -17
  25. package/dist/component/search.js.map +1 -1
  26. package/dist/shared.d.ts +2 -0
  27. package/dist/shared.d.ts.map +1 -1
  28. package/dist/shared.js +1 -0
  29. package/dist/shared.js.map +1 -1
  30. package/package.json +40 -38
  31. package/src/client/hybridRank.ts +1 -1
  32. package/src/client/index.test.ts +1 -1
  33. package/src/client/index.ts +80 -18
  34. package/src/component/_generated/component.ts +6 -1
  35. package/src/component/_generated/dataModel.ts +1 -1
  36. package/src/component/_generated/server.ts +0 -5
  37. package/src/component/chunks.ts +102 -92
  38. package/src/component/schema.ts +0 -1
  39. package/src/component/search.test.ts +303 -1
  40. package/src/component/search.ts +266 -19
  41. package/src/shared.ts +7 -0
@@ -34,6 +34,7 @@ import {
34
34
  vEntryId,
35
35
  vNamespaceId,
36
36
  vOnCompleteArgs,
37
+ vSearchType,
37
38
  type Chunk,
38
39
  type ChunkerAction,
39
40
  type CreateChunkArgs,
@@ -46,12 +47,13 @@ import {
46
47
  type OnCompleteNamespace,
47
48
  type SearchEntry,
48
49
  type SearchResult,
50
+ type SearchType,
49
51
  type Status,
50
52
  } from "../shared.js";
51
53
  import { defaultChunker } from "./defaultChunker.js";
52
54
 
53
55
  export { hybridRank } from "./hybridRank.js";
54
- export { defaultChunker, vEntryId, vNamespaceId };
56
+ export { defaultChunker, vEntryId, vNamespaceId, vSearchType };
55
57
  export type {
56
58
  ChunkerAction,
57
59
  Entry,
@@ -61,6 +63,7 @@ export type {
61
63
  OnCompleteNamespace,
62
64
  SearchEntry,
63
65
  SearchResult,
66
+ SearchType,
64
67
  Status,
65
68
  };
66
69
 
@@ -112,7 +115,7 @@ export class RAG<
112
115
  public component: ComponentApi,
113
116
  public options: {
114
117
  embeddingDimension: number;
115
- textEmbeddingModel: EmbeddingModel<string>;
118
+ textEmbeddingModel: EmbeddingModel;
116
119
  filterNames?: FilterNames<FitlerSchemas>;
117
120
  },
118
121
  ) {}
@@ -388,27 +391,56 @@ export class RAG<
388
391
  limit = DEFAULT_SEARCH_LIMIT,
389
392
  chunkContext = { before: 0, after: 0 },
390
393
  vectorScoreThreshold,
394
+ searchType = "vector",
395
+ textWeight,
396
+ vectorWeight,
391
397
  } = args;
392
- let embedding = Array.isArray(args.query) ? args.query : undefined;
398
+
399
+ const needsEmbedding = searchType !== "text";
400
+ let needsTextQuery = searchType !== "vector";
401
+
402
+ if (needsTextQuery && Array.isArray(args.query)) {
403
+ if (searchType === "text") {
404
+ throw new Error('searchType "text" requires a string query.');
405
+ }
406
+ console.warn(
407
+ `searchType "${searchType}" requires a string query. Falling back to vector-only search for embedding array queries.`,
408
+ );
409
+ needsTextQuery = false;
410
+ }
411
+
412
+ let embedding: number[] | undefined;
393
413
  let usage: EmbeddingModelUsage = { tokens: 0 };
394
- if (!embedding) {
395
- const embedResult = await embed({
396
- model: this.options.textEmbeddingModel,
397
- value: args.query,
398
- });
399
- embedding = embedResult.embedding;
400
- usage = embedResult.usage;
414
+ if (needsEmbedding) {
415
+ if (Array.isArray(args.query)) {
416
+ embedding = args.query;
417
+ } else {
418
+ const embedResult = await embed({
419
+ model: this.options.textEmbeddingModel,
420
+ value: args.query,
421
+ });
422
+ embedding = embedResult.embedding;
423
+ usage = embedResult.usage;
424
+ }
401
425
  }
426
+
427
+ const textQuery =
428
+ needsTextQuery && typeof args.query === "string" ? args.query : undefined;
429
+
402
430
  const { results, entries } = await ctx.runAction(
403
431
  this.component.search.search,
404
432
  {
405
433
  embedding,
434
+ dimension: this.options.embeddingDimension,
406
435
  namespace,
407
436
  modelId: getModelId(this.options.textEmbeddingModel),
408
437
  filters,
409
438
  limit,
410
439
  vectorScoreThreshold,
411
440
  chunkContext,
441
+ textQuery,
442
+ textWeight,
443
+ vectorWeight,
412
444
  },
413
445
  );
414
446
  const entriesWithTexts = entries.map((e) => {
@@ -975,27 +1007,27 @@ function makeBatches<T>(items: T[], batchSize: number): T[][] {
975
1007
  }
976
1008
 
977
1009
  async function createChunkArgsBatch(
978
- embedModel: EmbeddingModel<string>,
1010
+ embedModel: EmbeddingModel,
979
1011
  chunks: InputChunk[],
980
1012
  ): Promise<{ chunks: CreateChunkArgs[]; usage: EmbeddingModelUsage }> {
981
1013
  const argsMaybeMissingEmbeddings: (Omit<CreateChunkArgs, "embedding"> & {
982
1014
  embedding?: number[];
983
1015
  })[] = chunks.map((chunk) => {
984
1016
  if (typeof chunk === "string") {
985
- return { content: { text: chunk } };
1017
+ return { content: { text: chunk }, searchableText: chunk };
986
1018
  } else if ("text" in chunk) {
987
1019
  const { text, metadata, keywords: searchableText } = chunk;
988
1020
  return {
989
1021
  content: { text, metadata },
990
1022
  embedding: chunk.embedding,
991
- searchableText,
1023
+ searchableText: searchableText ?? text,
992
1024
  };
993
1025
  } else if ("pageContent" in chunk) {
994
1026
  const { pageContent: text, metadata, keywords: searchableText } = chunk;
995
1027
  return {
996
1028
  content: { text, metadata },
997
1029
  embedding: chunk.embedding,
998
- searchableText,
1030
+ searchableText: searchableText ?? text,
999
1031
  };
1000
1032
  } else {
1001
1033
  throw new Error("Invalid chunk: " + JSON.stringify(chunk));
@@ -1033,22 +1065,24 @@ async function createChunkArgsBatch(
1033
1065
 
1034
1066
  type MastraChunk = {
1035
1067
  text: string;
1036
- metadata: Record<string, Value>;
1068
+ metadata?: Record<string, Value>;
1037
1069
  embedding?: Array<number>;
1038
1070
  };
1039
1071
 
1040
1072
  type LangChainChunk = {
1041
1073
  id?: string;
1042
1074
  pageContent: string;
1043
- metadata: Record<string, Value>; //{ loc: { lines: { from: number; to: number } } };
1075
+ metadata?: Record<string, Value>; //{ loc: { lines: { from: number; to: number } } };
1044
1076
  embedding?: Array<number>;
1045
1077
  };
1046
1078
 
1047
1079
  export type InputChunk =
1048
1080
  | string
1049
1081
  | ((MastraChunk | LangChainChunk) & {
1050
- // Space-delimited keywords to text search on.
1051
- // TODO: implement text search
1082
+ /**
1083
+ * Text to use for full-text search. Defaults to the chunk's text content.
1084
+ * Provide a custom value to control what text is searchable.
1085
+ */
1052
1086
  keywords?: string;
1053
1087
  // In the future we can add per-chunk metadata if it's useful.
1054
1088
  // importance?: Importance;
@@ -1167,6 +1201,34 @@ type SearchOptions<FitlerSchemas extends Record<string, Value>> = {
1167
1201
  * The minimum score to return a result.
1168
1202
  */
1169
1203
  vectorScoreThreshold?: number;
1204
+ /**
1205
+ * The search mode to use.
1206
+ * - "vector": Vector similarity search only (default). Returns cosine
1207
+ * similarity scores.
1208
+ * - "text": Full-text search only. No embedding is computed. Returns
1209
+ * position-based scores.
1210
+ * - "hybrid": Combines vector and full-text search using Reciprocal Rank
1211
+ * Fusion. Returns position-based scores (1.0 for top result, decreasing
1212
+ * linearly).
1213
+ *
1214
+ * Text and hybrid modes require the query to be a string (not an embedding
1215
+ * array).
1216
+ */
1217
+ searchType?: SearchType;
1218
+ /**
1219
+ * Weight for text search results in hybrid ranking (RRF).
1220
+ * Higher values give more influence to text search matches.
1221
+ * Only used when searchType is "hybrid".
1222
+ * Default: 1
1223
+ */
1224
+ textWeight?: number;
1225
+ /**
1226
+ * Weight for vector search results in hybrid ranking (RRF).
1227
+ * Higher values give more influence to vector search matches.
1228
+ * Only used when searchType is "hybrid".
1229
+ * Default: 1
1230
+ */
1231
+ vectorWeight?: number;
1170
1232
  };
1171
1233
 
1172
1234
  function getModelCategory(model: string | { provider: string }) {
@@ -409,12 +409,17 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
409
409
  "internal",
410
410
  {
411
411
  chunkContext?: { after: number; before: number };
412
- embedding: Array<number>;
412
+ dimension?: number;
413
+ embedding?: Array<number>;
413
414
  filters: Array<{ name: string; value: any }>;
414
415
  limit: number;
415
416
  modelId: string;
416
417
  namespace: string;
418
+ searchType?: "vector" | "text" | "hybrid";
419
+ textQuery?: string;
420
+ textWeight?: number;
417
421
  vectorScoreThreshold?: number;
422
+ vectorWeight?: number;
418
423
  },
419
424
  {
420
425
  entries: Array<{
@@ -38,7 +38,7 @@ export type Doc<TableName extends TableNames> = DocumentByName<
38
38
  * Convex documents are uniquely identified by their `Id`, which is accessible
39
39
  * on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids).
40
40
  *
41
- * Documents can be loaded using `db.get(id)` in query and mutation functions.
41
+ * Documents can be loaded using `db.get(tableName, id)` in query and mutation functions.
42
42
  *
43
43
  * IDs are just strings at runtime, but this type can be used to distinguish them from other
44
44
  * strings when type checking.
@@ -107,11 +107,6 @@ export const internalAction: ActionBuilder<DataModel, "internal"> =
107
107
  */
108
108
  export const httpAction: HttpActionBuilder = httpActionGeneric;
109
109
 
110
- type GenericCtx =
111
- | GenericActionCtx<DataModel>
112
- | GenericMutationCtx<DataModel>
113
- | GenericQueryCtx<DataModel>;
114
-
115
110
  /**
116
111
  * A set of services for use within Convex query functions.
117
112
  *
@@ -311,6 +311,107 @@ export const vRangeResult = v.object({
311
311
  ),
312
312
  });
313
313
 
314
+ export async function buildRanges(
315
+ ctx: QueryCtx,
316
+ chunks: (Doc<"chunks"> | null)[],
317
+ chunkContext: { before: number; after: number },
318
+ ): Promise<{
319
+ ranges: (null | Infer<typeof vRangeResult>)[];
320
+ entries: Entry[];
321
+ }> {
322
+ // Note: This preserves order of entries as they first appeared.
323
+ const entryDocs = (
324
+ await Promise.all(
325
+ Array.from(
326
+ new Set(chunks.filter((c) => c !== null).map((c) => c.entryId)),
327
+ ).map((id) => ctx.db.get(id)),
328
+ )
329
+ ).filter((d): d is Doc<"entries"> => d !== null);
330
+ const entries = entryDocs.map(publicEntry);
331
+ const entryDocById = new Map(entryDocs.map((d) => [d._id, d]));
332
+
333
+ const entryOrders = chunks
334
+ .filter((c) => c !== null)
335
+ .map((c) => [c.entryId, c.order] as const)
336
+ .reduce(
337
+ (acc, [entryId, order]) => {
338
+ if (acc[entryId]?.includes(order)) {
339
+ // De-dupe orders
340
+ return acc;
341
+ }
342
+ acc[entryId] = [...(acc[entryId] ?? []), order].sort((a, b) => a - b);
343
+ return acc;
344
+ },
345
+ {} as Record<Id<"entries">, number[]>,
346
+ );
347
+
348
+ const result: Array<Infer<typeof vRangeResult> | null> = [];
349
+
350
+ for (const chunk of chunks) {
351
+ if (chunk === null) {
352
+ result.push(null);
353
+ continue;
354
+ }
355
+ // Note: if we parallelize this in the future, we could have a race
356
+ // instead we'd check that other chunks are not the same doc/order
357
+ if (
358
+ result.find(
359
+ (r) => r?.entryId === chunk.entryId && r?.order === chunk.order,
360
+ )
361
+ ) {
362
+ // De-dupe chunks
363
+ result.push(null);
364
+ continue;
365
+ }
366
+ const entryId = chunk.entryId;
367
+ const entry = entryDocById.get(entryId);
368
+ assert(entry, `Entry ${entryId} not found`);
369
+ const otherOrders = entryOrders[entryId] ?? [chunk.order];
370
+ const ourOrderIndex = otherOrders.indexOf(chunk.order);
371
+ const previousOrder = otherOrders[ourOrderIndex - 1] ?? -Infinity;
372
+ const nextOrder = otherOrders[ourOrderIndex + 1] ?? Infinity;
373
+ // We absorb all previous context up to the previous chunk.
374
+ const startOrder = Math.max(
375
+ chunk.order - chunkContext.before,
376
+ 0,
377
+ Math.min(previousOrder + 1, chunk.order),
378
+ );
379
+ // We stop short if the next chunk order's "before" context will cover it.
380
+ const endOrder = Math.min(
381
+ chunk.order + chunkContext.after + 1,
382
+ Math.max(nextOrder - chunkContext.before, chunk.order + 1),
383
+ );
384
+ const contentIds: Id<"content">[] = [];
385
+ if (startOrder === chunk.order && endOrder === chunk.order + 1) {
386
+ contentIds.push(chunk.contentId);
387
+ } else {
388
+ const rangeChunks = await ctx.db
389
+ .query("chunks")
390
+ .withIndex("entryId_order", (q) =>
391
+ q
392
+ .eq("entryId", entryId)
393
+ .gte("order", startOrder)
394
+ .lt("order", endOrder),
395
+ )
396
+ .collect();
397
+ for (const c of rangeChunks) {
398
+ contentIds.push(c.contentId);
399
+ }
400
+ }
401
+ const content = await Promise.all(
402
+ contentIds.map(async (contentId) => {
403
+ const content = await ctx.db.get(contentId);
404
+ assert(content, `Content ${contentId} not found`);
405
+ return { text: content.text, metadata: content.metadata };
406
+ }),
407
+ );
408
+
409
+ result.push({ entryId, order: chunk.order, startOrder, content });
410
+ }
411
+
412
+ return { ranges: result, entries };
413
+ }
414
+
314
415
  export const getRangesOfChunks = internalQuery({
315
416
  args: {
316
417
  embeddingIds: v.array(vVectorId),
@@ -339,98 +440,7 @@ export const getRangesOfChunks = internalQuery({
339
440
  .first(),
340
441
  ),
341
442
  );
342
-
343
- // Note: This preserves order of entries as they first appeared.
344
- const entries = (
345
- await Promise.all(
346
- Array.from(
347
- new Set(chunks.filter((c) => c !== null).map((c) => c.entryId)),
348
- ).map((id) => ctx.db.get(id)),
349
- )
350
- )
351
- .filter((d) => d !== null)
352
- .map(publicEntry);
353
-
354
- const entryOders = chunks
355
- .filter((c) => c !== null)
356
- .map((c) => [c.entryId, c.order] as const)
357
- .reduce(
358
- (acc, [entryId, order]) => {
359
- if (acc[entryId]?.includes(order)) {
360
- // De-dupe orders
361
- return acc;
362
- }
363
- acc[entryId] = [...(acc[entryId] ?? []), order].sort((a, b) => a - b);
364
- return acc;
365
- },
366
- {} as Record<Id<"entries">, number[]>,
367
- );
368
-
369
- const result: Array<Infer<typeof vRangeResult> | null> = [];
370
-
371
- for (const chunk of chunks) {
372
- if (chunk === null) {
373
- result.push(null);
374
- continue;
375
- }
376
- // Note: if we parallelize this in the future, we could have a race
377
- // instead we'd check that other chunks are not the same doc/order
378
- if (
379
- result.find(
380
- (r) => r?.entryId === chunk.entryId && r?.order === chunk.order,
381
- )
382
- ) {
383
- // De-dupe chunks
384
- result.push(null);
385
- continue;
386
- }
387
- const entryId = chunk.entryId;
388
- const entry = await ctx.db.get(entryId);
389
- assert(entry, `Entry ${entryId} not found`);
390
- const otherOrders = entryOders[entryId] ?? [chunk.order];
391
- const ourOrderIndex = otherOrders.indexOf(chunk.order);
392
- const previousOrder = otherOrders[ourOrderIndex - 1] ?? -Infinity;
393
- const nextOrder = otherOrders[ourOrderIndex + 1] ?? Infinity;
394
- // We absorb all previous context up to the previous chunk.
395
- const startOrder = Math.max(
396
- chunk.order - chunkContext.before,
397
- 0,
398
- Math.min(previousOrder + 1, chunk.order),
399
- );
400
- // We stop short if the next chunk order's "before" context will cover it.
401
- const endOrder = Math.min(
402
- chunk.order + chunkContext.after + 1,
403
- Math.max(nextOrder - chunkContext.before, chunk.order + 1),
404
- );
405
- const contentIds: Id<"content">[] = [];
406
- if (startOrder === chunk.order && endOrder === chunk.order + 1) {
407
- contentIds.push(chunk.contentId);
408
- } else {
409
- const chunks = await ctx.db
410
- .query("chunks")
411
- .withIndex("entryId_order", (q) =>
412
- q
413
- .eq("entryId", entryId)
414
- .gte("order", startOrder)
415
- .lt("order", endOrder),
416
- )
417
- .collect();
418
- for (const chunk of chunks) {
419
- contentIds.push(chunk.contentId);
420
- }
421
- }
422
- const content = await Promise.all(
423
- contentIds.map(async (contentId) => {
424
- const content = await ctx.db.get(contentId);
425
- assert(content, `Content ${contentId} not found`);
426
- return { text: content.text, metadata: content.metadata };
427
- }),
428
- );
429
-
430
- result.push({ entryId, order: chunk.order, startOrder, content });
431
- }
432
-
433
- return { ranges: result, entries };
443
+ return buildRanges(ctx, chunks, chunkContext);
434
444
  },
435
445
  });
436
446
 
@@ -71,7 +71,6 @@ export const schema = defineSchema({
71
71
  v.object({
72
72
  kind: v.literal("ready"),
73
73
  embeddingId: vVectorId,
74
- // TODO: text search
75
74
  searchableText: v.optional(v.string()),
76
75
  }),
77
76
  v.object({