@convex-dev/rag 0.3.0 → 0.3.2

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 (42) hide show
  1. package/README.md +463 -121
  2. package/dist/client/defaultChunker.d.ts.map +1 -1
  3. package/dist/client/defaultChunker.js +47 -16
  4. package/dist/client/defaultChunker.js.map +1 -1
  5. package/dist/client/fileUtils.d.ts +4 -2
  6. package/dist/client/fileUtils.d.ts.map +1 -1
  7. package/dist/client/fileUtils.js +5 -3
  8. package/dist/client/fileUtils.js.map +1 -1
  9. package/dist/client/index.d.ts +19 -15
  10. package/dist/client/index.d.ts.map +1 -1
  11. package/dist/client/index.js +15 -11
  12. package/dist/client/index.js.map +1 -1
  13. package/dist/component/_generated/api.d.ts +11 -4
  14. package/dist/component/chunks.d.ts +1 -0
  15. package/dist/component/chunks.d.ts.map +1 -1
  16. package/dist/component/chunks.js +2 -1
  17. package/dist/component/chunks.js.map +1 -1
  18. package/dist/component/entries.d.ts +8 -8
  19. package/dist/component/entries.d.ts.map +1 -1
  20. package/dist/component/entries.js +30 -17
  21. package/dist/component/entries.js.map +1 -1
  22. package/dist/component/namespaces.d.ts +3 -3
  23. package/dist/component/namespaces.js +4 -4
  24. package/dist/component/namespaces.js.map +1 -1
  25. package/dist/component/schema.d.ts +31 -31
  26. package/dist/shared.d.ts +28 -11
  27. package/dist/shared.d.ts.map +1 -1
  28. package/dist/shared.js +2 -1
  29. package/dist/shared.js.map +1 -1
  30. package/package.json +1 -6
  31. package/src/client/defaultChunker.test.ts +1 -1
  32. package/src/client/defaultChunker.ts +73 -17
  33. package/src/client/fileUtils.ts +8 -4
  34. package/src/client/index.test.ts +28 -24
  35. package/src/client/index.ts +30 -24
  36. package/src/component/_generated/api.d.ts +11 -4
  37. package/src/component/chunks.test.ts +2 -0
  38. package/src/component/chunks.ts +2 -1
  39. package/src/component/entries.test.ts +16 -16
  40. package/src/component/entries.ts +31 -19
  41. package/src/component/namespaces.ts +4 -4
  42. package/src/shared.ts +15 -7
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Convex RAG Component
2
2
 
3
- [![npm version](https://badge.fury.io/js/@convex-dev%2Fmemory.svg)](https://badge.fury.io/js/@convex-dev%2Fmemory)
3
+ [![npm version](https://badge.fury.io/js/@convex-dev%2Frag.svg)](https://badge.fury.io/js/@convex-dev%2Frag)
4
4
 
5
5
  <!-- START: Include on https://convex.dev/components -->
6
6
 
@@ -57,25 +57,15 @@ import { RAG } from "@convex-dev/rag";
57
57
  // Any AI SDK model that supports embeddings will work.
58
58
  import { openai } from "@ai-sdk/openai";
59
59
 
60
- const rag = new RAG<FilterTypes>(components.rag, {
61
- filterNames: ["category", "contentType", "categoryAndType"],
60
+ const rag = new RAG(components.rag, {
62
61
  textEmbeddingModel: openai.embedding("text-embedding-3-small"),
63
- embeddingDimension: 1536,
62
+ embeddingDimension: 1536, // Needs to match your embedding model
64
63
  });
65
-
66
- // Optional: Add type safety to your filters.
67
- type FilterTypes = {
68
- category: string;
69
- contentType: string;
70
- categoryAndType: { category: string; contentType: string };
71
- };
72
64
  ```
73
65
 
74
- ## Usage Examples
66
+ ## Add context to RAG
75
67
 
76
- ### Add RAG Entries
77
-
78
- Add content with text chunks.
68
+ Add content with text chunks. Each call to `add` will create a new **entry**.
79
69
  It will embed the chunks automatically if you don't provide them.
80
70
 
81
71
  ```ts
@@ -85,103 +75,15 @@ export const add = action({
85
75
  // Add the text to a namespace shared by all users.
86
76
  await rag.add(ctx, {
87
77
  namespace: "all-users",
88
- chunks: text.split("\n\n"),
89
- });
90
- },
91
- });
92
- ```
93
-
94
- ### Add Entries with filters from a URL
95
-
96
- Here's a simple example fetching content from a URL to add.
97
-
98
- It also adds filters to the entry, so you can search for it later by
99
- category, contentType, or both.
100
-
101
- ```ts
102
- export const add = action({
103
- args: { url: v.string(), category: v.string() },
104
- handler: async (ctx, { url, category }) => {
105
- const response = await fetch(url);
106
- const content = await response.text();
107
- const contentType = response.headers.get("content-type");
108
-
109
- const { entryId } = await rag.add(ctx, {
110
- namespace: "global", // namespace can be any string
111
- key: url,
112
- chunks: content.split("\n\n"),
113
- filterValues: [
114
- { name: "category", value: category },
115
- { name: "contentType", value: contentType },
116
- // To get an AND filter, use a filter with a more complex value.
117
- { name: "categoryAndType", value: { category, contentType } },
118
- ],
78
+ text,
119
79
  });
120
-
121
- return { entryId };
122
80
  },
123
81
  });
124
82
  ```
125
83
 
126
- Note: The `textSplitter` here could be LangChain, Mastra, or otherwise.
127
- See below for more details.
84
+ See below for how to chunk the text yourself or add content asynchronously, e.g. to handle large files.
128
85
 
129
- ### Add Entries Asynchronously using File Storage
130
-
131
- For large files, you can upload them to file storage, then provide a chunker
132
- action to split them into chunks.
133
-
134
- In `convex/http.ts`:
135
- ```ts
136
- import { corsRouter } from "convex-helpers/server/cors";
137
- import { httpRouter } from "convex/server";
138
- import { internal } from "./_generated/api.js";
139
- import { DataModel } from "./_generated/dataModel.js";
140
- import { httpAction } from "./_generated/server.js";
141
- import { rag } from "./example.js";
142
-
143
- const cors = corsRouter(httpRouter());
144
-
145
- cors.route({
146
- path: "/upload",
147
- method: "POST",
148
- handler: httpAction(async (ctx, request) => {
149
- const storageId = await ctx.storage.store(await request.blob());
150
- await rag.addAsync(ctx, {
151
- namespace: "all-files",
152
- chunkerAction: internal.http.chunkerAction,
153
- onComplete: internal.http.handleEntryComplete,
154
- metadata: { storageId },
155
- });
156
- return new Response();
157
- }),
158
- });
159
-
160
- export const chunkerAction = rag.defineChunkerAction(async (ctx, args) => {
161
- const storageId = args.entry.metadata!.storageId;
162
- const file = await ctx.storage.get(storageId);
163
- const text = await new TextDecoder().decode(await file!.arrayBuffer());
164
- return { chunks: text.split("\n\n") };
165
- });
166
-
167
- export const handleEntryComplete = rag.defineOnComplete<DataModel>(
168
- async (ctx, { replacedEntry, entry, namespace, error }) => {
169
- if (error) {
170
- await rag.delete(ctx, { entryId: entry.entryId });
171
- return;
172
- }
173
- // You can associate the entry with your own data here. This will commit
174
- // in the same transaction as the entry becoming ready.
175
- }
176
- );
177
-
178
- export default cors.http;
179
- ```
180
-
181
- You can upload files directly to a Convex action, httpAction, or upload url.
182
- See the [docs](https://docs.convex.dev/file-storage/upload-files) for details.
183
-
184
- ### Semantic Search
86
+ ## Semantic Search
185
87
 
186
88
  Search across content with vector similarity
187
89
 
@@ -202,7 +104,7 @@ export const search = action({
202
104
  const { results, text, entries } = await rag.search(ctx, {
203
105
  namespace: "global",
204
106
  query: args.query,
205
- limit: 10
107
+ limit: 10,
206
108
  vectorScoreThreshold: 0.5, // Only return results with a score >= 0.5
207
109
  });
208
110
 
@@ -211,15 +113,93 @@ export const search = action({
211
113
  });
212
114
  ```
213
115
 
214
- ### Filtered Search
116
+ ## Generate a response based on RAG context
117
+
118
+ Once you have searched for the context, you can use it with an LLM.
119
+
120
+ Generally you'll already be using something to make LLM requests, e.g.
121
+ the [Agent Component](https://www.convex.dev/components/agent),
122
+ which tracks the message history for you.
123
+ See the [Agent Component docs](https://docs.convex.dev/agents)
124
+ for more details on doing RAG with the Agent Component.
125
+
126
+ However, if you just want a one-off response, you can use the `generateText`
127
+ function as a convenience.
128
+
129
+ This will automatically search for relevant entries and use them as context
130
+ for the LLM, using default formatting.
131
+
132
+ The arguments to `generateText` are compatible with all arguments to
133
+ `generateText` from the AI SDK.
134
+
135
+ ```ts
136
+ export const askQuestion = action({
137
+ args: {
138
+ prompt: v.string(),
139
+ },
140
+ handler: async (ctx, args) => {
141
+ const userId = await getAuthUserId(ctx);
142
+ const { text, context } = await rag.generateText(ctx, {
143
+ search: { namespace: userId, limit: 10 },
144
+ prompt: args.prompt,
145
+ model: openai.chat("gpt-4o-mini"),
146
+ });
147
+ return { answer: text, context };
148
+ },
149
+ ```
150
+
151
+ Note: You can specify any of the search options available on `rag.search`.
152
+
153
+ ## Filtered Search
154
+
155
+ You can provide filters when adding content and use them to search.
156
+ To do this, you'll need to give the RAG component a list of the filter names.
157
+ You can optionally provide a type parameter for type safety (no runtime validation).
158
+
159
+ Note: these filters can be OR'd together when searching. In order to get an AND,
160
+ you provide a filter with a more complex value, such as `categoryAndType` below.
161
+
162
+ ```ts
163
+ // convex/example.ts
164
+ import { components } from "./_generated/api";
165
+ import { RAG } from "@convex-dev/rag";
166
+ // Any AI SDK model that supports embeddings will work.
167
+ import { openai } from "@ai-sdk/openai";
168
+
169
+ // Optional: Add type safety to your filters.
170
+ type FilterTypes = {
171
+ category: string;
172
+ contentType: string;
173
+ categoryAndType: { category: string; contentType: string };
174
+ };
175
+
176
+ const rag = new RAG<FilterTypes>(components.rag, {
177
+ textEmbeddingModel: openai.embedding("text-embedding-3-small"),
178
+ embeddingDimension: 1536, // Needs to match your embedding model
179
+ filterNames: ["category", "contentType", "categoryAndType"],
180
+ });
181
+ ```
182
+
183
+ Adding content with filters:
184
+
185
+ ```ts
186
+ await rag.add(ctx, {
187
+ namespace: "global",
188
+ text,
189
+ filterValues: [
190
+ { name: "category", value: "news" },
191
+ { name: "contentType", value: "article" },
192
+ { name: "categoryAndType", value: { category: "news", contentType: "article" } },
193
+ ],
194
+ });
195
+ ```
215
196
 
216
197
  Search with metadata filters:
217
198
 
218
199
  ```ts
219
- export const searchByCategory = action({
200
+ export const searchForNewsOrSports = action({
220
201
  args: {
221
202
  query: v.string(),
222
- category: v.string(),
223
203
  },
224
204
  handler: async (ctx, args) => {
225
205
  const userId = await getUserId(ctx);
@@ -228,7 +208,10 @@ export const searchByCategory = action({
228
208
  const results = await rag.search(ctx, {
229
209
  namespace: userId,
230
210
  query: args.query,
231
- filters: [{ name: "category", value: args.category }],
211
+ filters: [
212
+ { name: "category", value: "news" },
213
+ { name: "category", value: "sports" },
214
+ ],
232
215
  limit: 10,
233
216
  });
234
217
 
@@ -277,14 +260,14 @@ export const searchWithContext = action({
277
260
  });
278
261
  ```
279
262
 
280
- ### Formatting results
263
+ ## Formatting results
281
264
 
282
265
  Formatting the results for use in a prompt depends a bit on the use case.
283
266
  By default, the results will be sorted by score, not necessarily in the order
284
267
  they appear in the original text. You may want to sort them by the order they
285
268
  appear in the original text so they follow the flow of the original document.
286
269
 
287
- For convenienct, the `text` field of the search results is a string formatted
270
+ For convenience, the `text` field of the search results is a string formatted
288
271
  with `...` separating non-sequential chunks, `---` separating entries, and
289
272
  `# Title:` at each entry boundary (if titles are available).
290
273
 
@@ -294,14 +277,18 @@ console.log(text);
294
277
  ```
295
278
 
296
279
  ```txt
297
- # Title 1:
280
+ ## Title 1:
298
281
  Chunk 1 contents
299
282
  Chunk 2 contents
283
+
300
284
  ...
285
+
301
286
  Chunk 8 contents
302
287
  Chunk 9 contents
288
+
303
289
  ---
304
- # Title 2:
290
+
291
+ ## Title 2:
305
292
  Chunk 4 contents
306
293
  Chunk 5 contents
307
294
  ```
@@ -350,22 +337,377 @@ await generateText({
350
337
  });
351
338
  ```
352
339
 
340
+ ## Using keys to gracefully replace content
341
+
342
+ When you add content to a namespace, you can provide a `key` to uniquely identify the content.
343
+ If you add content with the same key, it will make a new entry to replace the old one.
344
+
345
+ ```ts
346
+ await rag.add(ctx, { namespace: userId, key: "my-file.txt", text });
347
+ ```
348
+
349
+ When a new document is added, it will start with a status of "pending" while
350
+ it chunks, embeds, and inserts the data into the database.
351
+ Once all data is inserted, it will iterate over the chunks and swap the old
352
+ content embeddings with the new ones, and then update the status to "ready",
353
+ marking the previous version as "replaced".
354
+
355
+ The old content is kept around by default, so in-flight searches will get
356
+ results for old vector search results.
357
+ See below for more details on deleting.
358
+
359
+ This means that if searches are happening while the document is being added,
360
+ they will see the old content results
361
+ This is useful if you want to add content to a namespace and then immediately
362
+ search for it, or if you want to add content to a namespace and then immediately
363
+ add more content to the same namespace.
364
+
365
+ ## Using your own content splitter
366
+
367
+ By default, the component uses the `defaultChunker` to split the content into chunks.
368
+ You can pass in your own content chunks to the `add` or `addAsync` functions.
369
+
370
+ ```ts
371
+ const chunks = await textSplitter.split(content);
372
+ await rag.add(ctx, { namespace: "global", chunks });
373
+ ```
374
+
375
+ Note: The `textSplitter` here could be LangChain, Mastra, or something custom.
376
+ The simplest version makes an array of strings like `content.split("\n")`.
377
+
378
+ Note: you can pass in an async iterator instead of an array to handle large content.
379
+ Or use the `addAsync` function (see below).
380
+
381
+
382
+ ## Providing custom embeddings per-chunk
383
+
384
+ In addition to the text, you can provide your own embeddings for each chunk.
385
+
386
+ This can be beneficial if you want to embed something other than the chunk
387
+ contents, e.g. a summary of each chunk.
388
+
389
+ ```ts
390
+ const chunks = await textSplitter.split(content);
391
+ const chunksWithEmbeddings = await Promise.all(chunks.map(async chunk => {
392
+ return {
393
+ ...chunk,
394
+ embedding: await embedSummary(chunk)
395
+ }
396
+ }));
397
+ await rag.add(ctx, { namespace: "global", chunks });
398
+ ```
399
+
400
+ ## Add Entries Asynchronously using File Storage
401
+
402
+ For large files, you can upload them to file storage, then provide a chunker
403
+ action to split them into chunks.
404
+
405
+ In `convex/http.ts`:
406
+ ```ts
407
+ import { corsRouter } from "convex-helpers/server/cors";
408
+ import { httpRouter } from "convex/server";
409
+ import { internal } from "./_generated/api.js";
410
+ import { DataModel } from "./_generated/dataModel.js";
411
+ import { httpAction } from "./_generated/server.js";
412
+ import { rag } from "./example.js";
413
+
414
+ const cors = corsRouter(httpRouter());
415
+
416
+ cors.route({
417
+ path: "/upload",
418
+ method: "POST",
419
+ handler: httpAction(async (ctx, request) => {
420
+ const storageId = await ctx.storage.store(await request.blob());
421
+ await rag.addAsync(ctx, {
422
+ namespace: "all-files",
423
+ chunkerAction: internal.http.chunkerAction,
424
+ onComplete: internal.foo.docComplete, // See next section
425
+ metadata: { storageId },
426
+ });
427
+ return new Response();
428
+ }),
429
+ });
430
+
431
+ export const chunkerAction = rag.defineChunkerAction(async (ctx, args) => {
432
+ const storageId = args.entry.metadata!.storageId;
433
+ const file = await ctx.storage.get(storageId);
434
+ const text = await new TextDecoder().decode(await file!.arrayBuffer());
435
+ return { chunks: text.split("\n\n") };
436
+ });
437
+
438
+ export default cors.http;
439
+ ```
440
+
441
+ You can upload files directly to a Convex action, httpAction, or upload url.
442
+ See the [docs](https://docs.convex.dev/file-storage/upload-files) for details.
443
+
444
+ ### OnComplete Handling
445
+
446
+ You can register an `onComplete` handler when adding content that will be called
447
+ when the entry is ready, or if there was an error or it was replaced before it
448
+ finished.
449
+
450
+ ```ts
451
+ // in an action
452
+ await rag.add(ctx, { namespace, text, onComplete: internal.foo.docComplete });
453
+
454
+ // in convex/foo.ts
455
+ export const docComplete = rag.defineOnComplete<DataModel>(
456
+ async (ctx, { replacedEntry, entry, namespace, error }) => {
457
+ if (error) {
458
+ await rag.delete(ctx, { entryId: entry.entryId });
459
+ return;
460
+ }
461
+ if (replacedEntry) {
462
+ await rag.delete(ctx, { entryId: replacedEntry.entryId });
463
+ }
464
+ // You can associate the entry with your own data here. This will commit
465
+ // in the same transaction as the entry becoming ready.
466
+ }
467
+ );
468
+ ```
469
+
470
+ ### Add Entries with filters from a URL
471
+
472
+ Here's a simple example fetching content from a URL to add.
473
+
474
+ It also adds filters to the entry, so you can search for it later by
475
+ category, contentType, or both.
476
+
477
+ ```ts
478
+ export const add = action({
479
+ args: { url: v.string(), category: v.string() },
480
+ handler: async (ctx, { url, category }) => {
481
+ const response = await fetch(url);
482
+ const content = await response.text();
483
+ const contentType = response.headers.get("content-type");
484
+
485
+ const { entryId } = await rag.add(ctx, {
486
+ namespace: "global", // namespace can be any string
487
+ key: url,
488
+ chunks: content.split("\n\n"),
489
+ filterValues: [
490
+ { name: "category", value: category },
491
+ { name: "contentType", value: contentType },
492
+ // To get an AND filter, use a filter with a more complex value.
493
+ { name: "categoryAndType", value: { category, contentType } },
494
+ ],
495
+ });
496
+
497
+ return { entryId };
498
+ },
499
+ });
500
+ ```
501
+
353
502
  ### Lifecycle Management
354
503
 
355
- Delete an entry:
504
+ You can delete the old content by calling `rag.delete` with the entryId of the
505
+ old version.
506
+
507
+ Generally you'd do this:
508
+
509
+ 1. When using `rag.add` with a key returns a `replacedEntry`.
510
+ 1. When your `onComplete` handler provides a non-null `replacedEntry` argument.
511
+ 1. Periodically by querying:
356
512
 
357
513
  ```ts
358
- export const delete = mutation({
359
- args: { entryId: vEntry },
514
+ // in convex/crons.ts
515
+ import { cronJobs } from "convex/server";
516
+ import { internal } from "./_generated/api.js";
517
+ import { internalMutation } from "./_generated/server.js";
518
+ import { v } from "convex/values";
519
+ import { rag } from "./example.js";
520
+ import { assert } from "convex-helpers";
521
+
522
+ const WEEK = 7 * 24 * 60 * 60 * 1000;
523
+
524
+ export const deleteOldContent = internalMutation({
525
+ args: { cursor: v.optional(v.string()) },
360
526
  handler: async (ctx, args) => {
361
- await rag.delete(ctx, {
362
- entryId: args.entryId,
527
+ const toDelete = await rag.list(ctx, {
528
+ status: "replaced",
529
+ paginationOpts: { cursor: args.cursor ?? null, numItems: 100 },
530
+ });
531
+
532
+ for (const entry of toDelete.page) {
533
+ assert(entry.status === "replaced");
534
+ if (entry.replacedAt >= Date.now() - WEEK) {
535
+ return; // we're done when we catch up to a week ago
536
+ }
537
+ await rag.delete(ctx, { entryId: entry.entryId });
538
+ }
539
+ if (!toDelete.isDone) {
540
+ await ctx.scheduler.runAfter(0, internal.example.deleteOldContent, {
541
+ cursor: toDelete.continueCursor,
542
+ });
543
+ }
544
+ },
545
+ });
546
+
547
+ // See example/convex/crons.ts for a complete example.
548
+ const crons = cronJobs();
549
+ crons.interval("deleteOldContent", { hours: 1 }, internal.crons.deleteOldContent, {});
550
+ export default crons;
551
+ ```
552
+
553
+ ## Working with types
554
+
555
+ You can use the provided types to validate and store data.
556
+ `import { ... } from "@convex-dev/rag";`
557
+
558
+ Types for the various elements:
559
+
560
+ `Entry`, `EntryFilter`, `SearchEntry`, `SearchResult`
561
+
562
+ - `SearchEntry` is an `Entry` with a `text` field including the combined search
563
+ results for that entry, whereas a `SearchResult` is a specific chunk result,
564
+ along with surrounding chunks.
565
+
566
+ `EntryId`, `NamespaceId`
567
+
568
+ - While the `EntryId` and `NamespaceId` are strings under the hood, they are
569
+ given more specific types to make it easier to use them correctly.
570
+
571
+ Validators can be used in `args` and schema table definitions:
572
+ `vEntry`, `vEntryId`, `vNamespaceId`, `vSearchEntry`, `vSearchResult`
573
+
574
+ e.g. `defineTable({ myDocTitle: v.string(), entryId: vEntryId })`
575
+
576
+ The validators for the branded IDs will only validate they are strings,
577
+ but will have the more specific types, to provide type safety.
578
+
579
+ ## Utility Functions
580
+
581
+ In addition to the function on the `rag` instance, there are other utilities
582
+ provided:
583
+
584
+ ### `defaultChunker`
585
+
586
+ This is the default chunker used by the `add` and `addAsync` functions.
587
+
588
+ It is customizable, but by default:
589
+ - It tries to break up the text into paragraphs between 100-1k characters.
590
+ - It will combine paragraphs to meet the minimum character count (100).
591
+ - It will break up paragraphs into separate lines to keep it under 1k.
592
+ - It will not split up a single line unless it's longer than 10k characters.
593
+
594
+ ```ts
595
+ import { defaultChunker } from "@convex-dev/rag";
596
+
597
+ const chunks = defaultChunker(text, {
598
+ // these are the defaults
599
+ minLines: 1,
600
+ minCharsSoftLimit: 100,
601
+ maxCharsSoftLimit: 1000,
602
+ maxCharsHardLimit: 10000,
603
+ delimiter: "\n\n",
604
+ });
605
+ ```
606
+
607
+ ### `hybridRank`
608
+
609
+ This is an implementation of "Reciprocal Rank Fusion" for ranking search results
610
+ based on multiple scoring arrays. The premise is that if both arrays of results
611
+ are sorted by score, the best results show up near the top of both arrays and
612
+ should be preferred over results higher in one but much lower in the other.
613
+
614
+ ```ts
615
+ import { hybridRank } from "@convex-dev/rag";
616
+
617
+ const textSearchResults = [id1, id2, id3];
618
+ const vectorSearchResults = [id2, id3, id1];
619
+ const results = hybridRank([
620
+ textSearchResults,
621
+ vectorSearchResults,
622
+ ]);
623
+ // results = [id2, id1, id3]
624
+ ```
625
+
626
+ It can take more than two arrays, and you can provide weights for each array.
627
+
628
+ ```ts
629
+
630
+ const recentSearchResults = [id5, id4, id3];
631
+ const results = hybridRank([
632
+ textSearchResults,
633
+ vectorSearchResults,
634
+ recentSearchResults,
635
+ ], {
636
+ weights: [2, 1, 3], // prefer recent results more than text or vector
637
+ });
638
+ // results = [ id3, id5, id1, id2, id4 ]
639
+ ```
640
+
641
+ To have it more biased towards the top few results, you can set the `k` value
642
+ to a lower number (10 by default).
643
+
644
+ ```ts
645
+ const results = hybridRank([
646
+ textSearchResults,
647
+ vectorSearchResults,
648
+ recentSearchResults,
649
+ ], { k: 1 });
650
+ // results = [ id5, id1, id3, id2, id4 ]
651
+ ```
652
+
653
+ ### `contentHashFromArrayBuffer`
654
+
655
+ This generates the hash of a file's contents, which can be used to avoid
656
+ adding the same file twice.
657
+
658
+ Note: doing `blob.arrayBuffer()` will consume the blob's data, so you'll need
659
+ to make a new blob to use it after calling this function.
660
+
661
+ ```ts
662
+ import { contentHashFromArrayBuffer } from "@convex-dev/rag";
663
+
664
+ export const addFile = action({
665
+ args: { bytes: v.bytes() },
666
+ handler: async (ctx, { bytes }) => {
667
+
668
+ const hash = await contentHashFromArrayBuffer(bytes);
669
+
670
+ const existing = await rag.findEntryByContentHash(ctx, {
671
+ namespace: "global",
672
+ key: "my-file.txt",
673
+ contentHash: hash,
363
674
  });
675
+ if (existing) {
676
+ console.log("File contents are the same, skipping");
677
+ return;
678
+ }
679
+ const blob = new Blob([bytes], { type: "text/plain" });
680
+ //...
364
681
  },
365
682
  });
366
683
  ```
367
684
 
685
+ ### `guessMimeTypeFromExtension`
686
+
687
+ This guesses the mime type of a file from its extension.
688
+
689
+ ```ts
690
+ import { guessMimeTypeFromExtension } from "@convex-dev/rag";
691
+
692
+ const mimeType = guessMimeTypeFromExtension("my-file.mjs");
693
+ console.log(mimeType); // "text/javascript"
694
+ ```
695
+
696
+ ### `guessMimeTypeFromContents`
697
+
698
+ This guesses the mime type of a file from the first few bytes of its contents.
699
+
700
+ ```ts
701
+ import { guessMimeTypeFromContents } from "@convex-dev/rag";
702
+
703
+ const mimeType = guessMimeTypeFromContents(await file.arrayBuffer());
704
+ ```
705
+
706
+ ### Example Usage
707
+
368
708
  See more example usage in [example.ts](./example/convex/example.ts).
369
709
 
370
- Run the example with `npm i && npm run example`.
710
+ ### Running the example
711
+
712
+ Run the example with `npm i && npm run setup && npm run example`.
371
713
  <!-- END: Include on https://convex.dev/components -->
@@ -1 +1 @@
1
- {"version":3,"file":"defaultChunker.d.ts","sourceRoot":"","sources":["../../src/client/defaultChunker.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,wBAAgB,cAAc,CAC5B,IAAI,EAAE,MAAM,EACZ,EACE,QAAY,EACZ,iBAAuB,EACvB,iBAAwB,EACxB,iBAAyB,EACzB,SAAkB,GACnB,GAAE;IACD,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,SAAS,CAAC,EAAE,MAAM,CAAC;CACf,GACL,MAAM,EAAE,CA6FV;AAoED,eAAe,cAAc,CAAC"}
1
+ {"version":3,"file":"defaultChunker.d.ts","sourceRoot":"","sources":["../../src/client/defaultChunker.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,wBAAgB,cAAc,CAC5B,IAAI,EAAE,MAAM,EACZ,EACE,QAAY,EACZ,iBAAuB,EACvB,iBAAwB,EACxB,iBAAyB,EACzB,SAAkB,GACnB,GAAE;IACD,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,SAAS,CAAC,EAAE,MAAM,CAAC;CACf,GACL,MAAM,EAAE,CA6HV;AA4FD,eAAe,cAAc,CAAC"}