@convex-dev/rag 0.1.7
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/LICENSE +201 -0
- package/README.md +371 -0
- package/dist/client/_generated/_ignore.d.ts +1 -0
- package/dist/client/_generated/_ignore.d.ts.map +1 -0
- package/dist/client/_generated/_ignore.js +3 -0
- package/dist/client/_generated/_ignore.js.map +1 -0
- package/dist/client/defaultChunker.d.ts +15 -0
- package/dist/client/defaultChunker.d.ts.map +1 -0
- package/dist/client/defaultChunker.js +148 -0
- package/dist/client/defaultChunker.js.map +1 -0
- package/dist/client/fileUtils.d.ts +24 -0
- package/dist/client/fileUtils.d.ts.map +1 -0
- package/dist/client/fileUtils.js +179 -0
- package/dist/client/fileUtils.js.map +1 -0
- package/dist/client/index.d.ts +442 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +597 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client/types.d.ts +29 -0
- package/dist/client/types.d.ts.map +1 -0
- package/dist/client/types.js +2 -0
- package/dist/client/types.js.map +1 -0
- package/dist/component/_generated/api.d.ts +439 -0
- package/dist/component/_generated/api.d.ts.map +1 -0
- package/dist/component/_generated/api.js +22 -0
- package/dist/component/_generated/api.js.map +1 -0
- package/dist/component/_generated/dataModel.d.ts +60 -0
- package/dist/component/_generated/server.d.ts +149 -0
- package/dist/component/_generated/server.d.ts.map +1 -0
- package/dist/component/_generated/server.js +74 -0
- package/dist/component/_generated/server.js.map +1 -0
- package/dist/component/chunks.d.ts +139 -0
- package/dist/component/chunks.d.ts.map +1 -0
- package/dist/component/chunks.js +413 -0
- package/dist/component/chunks.js.map +1 -0
- package/dist/component/convex.config.d.ts +3 -0
- package/dist/component/convex.config.d.ts.map +1 -0
- package/dist/component/convex.config.js +6 -0
- package/dist/component/convex.config.js.map +1 -0
- package/dist/component/embeddings/importance.d.ts +21 -0
- package/dist/component/embeddings/importance.d.ts.map +1 -0
- package/dist/component/embeddings/importance.js +67 -0
- package/dist/component/embeddings/importance.js.map +1 -0
- package/dist/component/embeddings/index.d.ts +23 -0
- package/dist/component/embeddings/index.d.ts.map +1 -0
- package/dist/component/embeddings/index.js +54 -0
- package/dist/component/embeddings/index.js.map +1 -0
- package/dist/component/embeddings/tables.d.ts +39 -0
- package/dist/component/embeddings/tables.d.ts.map +1 -0
- package/dist/component/embeddings/tables.js +53 -0
- package/dist/component/embeddings/tables.js.map +1 -0
- package/dist/component/entries.d.ts +167 -0
- package/dist/component/entries.d.ts.map +1 -0
- package/dist/component/entries.js +409 -0
- package/dist/component/entries.js.map +1 -0
- package/dist/component/filters.d.ts +46 -0
- package/dist/component/filters.d.ts.map +1 -0
- package/dist/component/filters.js +72 -0
- package/dist/component/filters.js.map +1 -0
- package/dist/component/namespaces.d.ts +131 -0
- package/dist/component/namespaces.d.ts.map +1 -0
- package/dist/component/namespaces.js +222 -0
- package/dist/component/namespaces.js.map +1 -0
- package/dist/component/schema.d.ts +1697 -0
- package/dist/component/schema.d.ts.map +1 -0
- package/dist/component/schema.js +88 -0
- package/dist/component/schema.js.map +1 -0
- package/dist/component/search.d.ts +20 -0
- package/dist/component/search.d.ts.map +1 -0
- package/dist/component/search.js +69 -0
- package/dist/component/search.js.map +1 -0
- package/dist/package.json +3 -0
- package/dist/react/index.d.ts +2 -0
- package/dist/react/index.d.ts.map +1 -0
- package/dist/react/index.js +6 -0
- package/dist/react/index.js.map +1 -0
- package/dist/shared.d.ts +479 -0
- package/dist/shared.d.ts.map +1 -0
- package/dist/shared.js +98 -0
- package/dist/shared.js.map +1 -0
- package/package.json +97 -0
- package/src/client/_generated/_ignore.ts +1 -0
- package/src/client/defaultChunker.test.ts +243 -0
- package/src/client/defaultChunker.ts +183 -0
- package/src/client/fileUtils.ts +179 -0
- package/src/client/index.test.ts +475 -0
- package/src/client/index.ts +1125 -0
- package/src/client/setup.test.ts +28 -0
- package/src/client/types.ts +69 -0
- package/src/component/_generated/api.d.ts +439 -0
- package/src/component/_generated/api.js +23 -0
- package/src/component/_generated/dataModel.d.ts +60 -0
- package/src/component/_generated/server.d.ts +149 -0
- package/src/component/_generated/server.js +90 -0
- package/src/component/chunks.test.ts +915 -0
- package/src/component/chunks.ts +555 -0
- package/src/component/convex.config.ts +7 -0
- package/src/component/embeddings/importance.test.ts +249 -0
- package/src/component/embeddings/importance.ts +75 -0
- package/src/component/embeddings/index.test.ts +482 -0
- package/src/component/embeddings/index.ts +99 -0
- package/src/component/embeddings/tables.ts +114 -0
- package/src/component/entries.test.ts +341 -0
- package/src/component/entries.ts +546 -0
- package/src/component/filters.ts +119 -0
- package/src/component/namespaces.ts +299 -0
- package/src/component/schema.ts +106 -0
- package/src/component/search.test.ts +445 -0
- package/src/component/search.ts +97 -0
- package/src/component/setup.test.ts +5 -0
- package/src/react/index.ts +7 -0
- package/src/shared.ts +247 -0
- package/src/vitest.config.ts +7 -0
|
@@ -0,0 +1,1125 @@
|
|
|
1
|
+
import type { EmbeddingModelV1 } from "@ai-sdk/provider";
|
|
2
|
+
import { embed, embedMany, generateText, type CoreMessage } from "ai";
|
|
3
|
+
import { assert } from "convex-helpers";
|
|
4
|
+
import {
|
|
5
|
+
createFunctionHandle,
|
|
6
|
+
internalActionGeneric,
|
|
7
|
+
internalMutationGeneric,
|
|
8
|
+
type FunctionArgs,
|
|
9
|
+
type FunctionHandle,
|
|
10
|
+
type FunctionReturnType,
|
|
11
|
+
type GenericActionCtx,
|
|
12
|
+
type GenericDataModel,
|
|
13
|
+
type GenericMutationCtx,
|
|
14
|
+
type PaginationOptions,
|
|
15
|
+
type PaginationResult,
|
|
16
|
+
type RegisteredAction,
|
|
17
|
+
type RegisteredMutation,
|
|
18
|
+
} from "convex/server";
|
|
19
|
+
import { type Value } from "convex/values";
|
|
20
|
+
import {
|
|
21
|
+
CHUNK_BATCH_SIZE,
|
|
22
|
+
filterNamesContain,
|
|
23
|
+
vChunkerArgs,
|
|
24
|
+
vEntryId,
|
|
25
|
+
vNamespaceId,
|
|
26
|
+
vOnCompleteArgs,
|
|
27
|
+
type Chunk,
|
|
28
|
+
type CreateChunkArgs,
|
|
29
|
+
type Entry,
|
|
30
|
+
type EntryFilterValues,
|
|
31
|
+
type EntryId,
|
|
32
|
+
type Namespace,
|
|
33
|
+
type NamespaceId,
|
|
34
|
+
type SearchEntry,
|
|
35
|
+
type SearchResult,
|
|
36
|
+
type Status,
|
|
37
|
+
} from "../shared.js";
|
|
38
|
+
import {
|
|
39
|
+
type RAGComponent,
|
|
40
|
+
type RunActionCtx,
|
|
41
|
+
type RunMutationCtx,
|
|
42
|
+
type RunQueryCtx,
|
|
43
|
+
} from "./types.js";
|
|
44
|
+
import {
|
|
45
|
+
type ChunkerAction,
|
|
46
|
+
type OnComplete,
|
|
47
|
+
type OnCompleteNamespace,
|
|
48
|
+
} from "../shared.js";
|
|
49
|
+
import type { NamedFilter } from "../component/filters.js";
|
|
50
|
+
import { defaultChunker } from "./defaultChunker.js";
|
|
51
|
+
|
|
52
|
+
export { defaultChunker, vEntryId, vNamespaceId };
|
|
53
|
+
export type {
|
|
54
|
+
ChunkerAction,
|
|
55
|
+
Entry,
|
|
56
|
+
EntryId,
|
|
57
|
+
RAGComponent,
|
|
58
|
+
NamespaceId,
|
|
59
|
+
OnComplete,
|
|
60
|
+
OnCompleteNamespace,
|
|
61
|
+
SearchEntry,
|
|
62
|
+
SearchResult,
|
|
63
|
+
Status,
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export {
|
|
67
|
+
type VEntry,
|
|
68
|
+
type VSearchEntry,
|
|
69
|
+
vEntry,
|
|
70
|
+
vSearchEntry,
|
|
71
|
+
vSearchResult,
|
|
72
|
+
vOnCompleteArgs,
|
|
73
|
+
} from "../shared.js";
|
|
74
|
+
export {
|
|
75
|
+
contentHashFromArrayBuffer,
|
|
76
|
+
guessMimeTypeFromExtension,
|
|
77
|
+
guessMimeTypeFromContents,
|
|
78
|
+
} from "./fileUtils.js";
|
|
79
|
+
|
|
80
|
+
const DEFAULT_SEARCH_LIMIT = 10;
|
|
81
|
+
|
|
82
|
+
// This is 0-1 with 1 being the most important and 0 being totally irrelevant.
|
|
83
|
+
// Used for vector search weighting.
|
|
84
|
+
type Importance = number;
|
|
85
|
+
|
|
86
|
+
export class RAG<
|
|
87
|
+
FitlerSchemas extends Record<string, Value> = Record<string, Value>,
|
|
88
|
+
EntryMetadata extends Record<string, Value> = Record<string, Value>,
|
|
89
|
+
> {
|
|
90
|
+
/**
|
|
91
|
+
* A component to use for Retrieval-Augmented Generation.
|
|
92
|
+
* Create one for each model and embedding dimension you want to use.
|
|
93
|
+
* When migrating between models / embedding lengths, create multiple
|
|
94
|
+
* instances and do your `add`s with the appropriate instance, and searches
|
|
95
|
+
* against the appropriate instance to get results with those parameters.
|
|
96
|
+
*
|
|
97
|
+
* The filterNames need to match the names of the filters you provide when
|
|
98
|
+
* adding and when searching. Use the type parameter to make this type safe.
|
|
99
|
+
*
|
|
100
|
+
* The second type parameter makes the entry metadata type safe. E.g. you can
|
|
101
|
+
* do rag.add(ctx, {
|
|
102
|
+
* namespace: "my-namespace",
|
|
103
|
+
* metadata: {
|
|
104
|
+
* source: "website" as const,
|
|
105
|
+
* },
|
|
106
|
+
* })
|
|
107
|
+
* and then entry results will have the metadata type `{ source: "website" }`.
|
|
108
|
+
*/
|
|
109
|
+
constructor(
|
|
110
|
+
public component: RAGComponent,
|
|
111
|
+
public options: {
|
|
112
|
+
embeddingDimension: number;
|
|
113
|
+
textEmbeddingModel: EmbeddingModelV1<string>;
|
|
114
|
+
filterNames?: FilterNames<FitlerSchemas>;
|
|
115
|
+
}
|
|
116
|
+
) {}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Add an entry to the store. It will chunk the text with the `defaultChunker`
|
|
120
|
+
* if you don't provide chunks, and embed the chunks with the default model
|
|
121
|
+
* if you don't provide chunk embeddings.
|
|
122
|
+
*
|
|
123
|
+
* If you provide a key, it will replace an existing entry with the same key.
|
|
124
|
+
* If you don't provide a key, it will always create a new entry.
|
|
125
|
+
* If you provide a contentHash, it will deduplicate the entry if it already exists.
|
|
126
|
+
* The filterValues you provide can be used later to search for it.
|
|
127
|
+
*/
|
|
128
|
+
async add(
|
|
129
|
+
ctx: RunMutationCtx,
|
|
130
|
+
args: NamespaceSelection &
|
|
131
|
+
EntryArgs<FitlerSchemas, EntryMetadata> &
|
|
132
|
+
(
|
|
133
|
+
| {
|
|
134
|
+
/**
|
|
135
|
+
* You can provide your own chunks to finely control the splitting.
|
|
136
|
+
* These can also include your own provided embeddings, so you can
|
|
137
|
+
* control what content is embedded, which can differ from the content
|
|
138
|
+
* in the chunks.
|
|
139
|
+
*/
|
|
140
|
+
chunks: Iterable<InputChunk> | AsyncIterable<InputChunk>;
|
|
141
|
+
/** @deprecated You cannot specify both chunks and text currently. */
|
|
142
|
+
text?: undefined;
|
|
143
|
+
}
|
|
144
|
+
| {
|
|
145
|
+
/**
|
|
146
|
+
* If you don't provide chunks, we will split the text into chunks
|
|
147
|
+
* using the default chunker and embed them with the default model.
|
|
148
|
+
*/
|
|
149
|
+
text: string;
|
|
150
|
+
/** @deprecated You cannot specify both chunks and text currently. */
|
|
151
|
+
chunks?: undefined;
|
|
152
|
+
}
|
|
153
|
+
)
|
|
154
|
+
): Promise<{
|
|
155
|
+
entryId: EntryId;
|
|
156
|
+
status: Status;
|
|
157
|
+
created: boolean;
|
|
158
|
+
replacedVersion: Entry<FitlerSchemas, EntryMetadata> | null;
|
|
159
|
+
}> {
|
|
160
|
+
let namespaceId: NamespaceId;
|
|
161
|
+
if ("namespaceId" in args) {
|
|
162
|
+
namespaceId = args.namespaceId;
|
|
163
|
+
} else {
|
|
164
|
+
const namespace = await this.getOrCreateNamespace(ctx, {
|
|
165
|
+
namespace: args.namespace,
|
|
166
|
+
status: "ready",
|
|
167
|
+
});
|
|
168
|
+
namespaceId = namespace.namespaceId;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
validateAddFilterValues(args.filterValues, this.options.filterNames);
|
|
172
|
+
|
|
173
|
+
const chunks = args.chunks ?? defaultChunker(args.text);
|
|
174
|
+
let allChunks: CreateChunkArgs[] | undefined;
|
|
175
|
+
if (Array.isArray(chunks) && chunks.length < CHUNK_BATCH_SIZE) {
|
|
176
|
+
allChunks = await createChunkArgsBatch(
|
|
177
|
+
this.options.textEmbeddingModel,
|
|
178
|
+
chunks
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const onComplete =
|
|
183
|
+
args.onComplete && (await createFunctionHandle(args.onComplete));
|
|
184
|
+
|
|
185
|
+
const { entryId, status, created, replacedVersion } = await ctx.runMutation(
|
|
186
|
+
this.component.entries.add,
|
|
187
|
+
{
|
|
188
|
+
entry: {
|
|
189
|
+
key: args.key,
|
|
190
|
+
namespaceId,
|
|
191
|
+
title: args.title,
|
|
192
|
+
metadata: args.metadata,
|
|
193
|
+
filterValues: args.filterValues ?? [],
|
|
194
|
+
importance: args.importance ?? 1,
|
|
195
|
+
contentHash: args.contentHash,
|
|
196
|
+
},
|
|
197
|
+
onComplete,
|
|
198
|
+
allChunks,
|
|
199
|
+
}
|
|
200
|
+
);
|
|
201
|
+
if (status === "ready") {
|
|
202
|
+
return {
|
|
203
|
+
entryId: entryId as EntryId,
|
|
204
|
+
status,
|
|
205
|
+
created,
|
|
206
|
+
replacedVersion: replacedVersion as Entry<
|
|
207
|
+
FitlerSchemas,
|
|
208
|
+
EntryMetadata
|
|
209
|
+
> | null,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// break chunks up into batches, respecting soft limit
|
|
214
|
+
let startOrder = 0;
|
|
215
|
+
let isPending = false;
|
|
216
|
+
for await (const batch of batchIterator(chunks, CHUNK_BATCH_SIZE)) {
|
|
217
|
+
const chunks = await createChunkArgsBatch(
|
|
218
|
+
this.options.textEmbeddingModel,
|
|
219
|
+
batch
|
|
220
|
+
);
|
|
221
|
+
const { status } = await ctx.runMutation(this.component.chunks.insert, {
|
|
222
|
+
entryId,
|
|
223
|
+
startOrder,
|
|
224
|
+
chunks,
|
|
225
|
+
});
|
|
226
|
+
startOrder += chunks.length;
|
|
227
|
+
if (status === "pending") {
|
|
228
|
+
isPending = true;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
if (isPending) {
|
|
232
|
+
let startOrder = 0;
|
|
233
|
+
// replace any older version of the entry with the new one
|
|
234
|
+
while (true) {
|
|
235
|
+
const { status, nextStartOrder } = await ctx.runMutation(
|
|
236
|
+
this.component.chunks.replaceChunksPage,
|
|
237
|
+
{ entryId, startOrder }
|
|
238
|
+
);
|
|
239
|
+
if (status === "ready") {
|
|
240
|
+
break;
|
|
241
|
+
} else if (status === "replaced") {
|
|
242
|
+
return {
|
|
243
|
+
entryId: entryId as EntryId,
|
|
244
|
+
status: "replaced" as const,
|
|
245
|
+
created: false,
|
|
246
|
+
replacedVersion: null,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
startOrder = nextStartOrder;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
const promoted = await ctx.runMutation(
|
|
253
|
+
this.component.entries.promoteToReady,
|
|
254
|
+
{ entryId }
|
|
255
|
+
);
|
|
256
|
+
return {
|
|
257
|
+
entryId: entryId as EntryId,
|
|
258
|
+
status: "ready" as const,
|
|
259
|
+
replacedVersion: promoted.replacedVersion as Entry<
|
|
260
|
+
FitlerSchemas,
|
|
261
|
+
EntryMetadata
|
|
262
|
+
> | null,
|
|
263
|
+
created: true,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Add an entry to the store asynchronously.
|
|
269
|
+
*
|
|
270
|
+
* This is useful if you want to chunk the entry in a separate process,
|
|
271
|
+
* or if you want to chunk the entry in a separate process.
|
|
272
|
+
*
|
|
273
|
+
* The chunkerAction is a function that splits the entry into chunks and
|
|
274
|
+
* embeds them. It should be passed as internal.foo.myChunkerAction
|
|
275
|
+
* e.g.
|
|
276
|
+
* ```ts
|
|
277
|
+
* export const myChunkerAction = rag.defineChunkerAction(async (ctx, args) => {
|
|
278
|
+
* // ...
|
|
279
|
+
* return { chunks: [chunk1, chunk2, chunk3] };
|
|
280
|
+
* });
|
|
281
|
+
*
|
|
282
|
+
* // in your mutation
|
|
283
|
+
* const entryId = await rag.addAsync(ctx, {
|
|
284
|
+
* key: "myfile.txt",
|
|
285
|
+
* namespace: "my-namespace",
|
|
286
|
+
* chunkerAction: internal.foo.myChunkerAction,
|
|
287
|
+
* });
|
|
288
|
+
* ```
|
|
289
|
+
*/
|
|
290
|
+
async addAsync(
|
|
291
|
+
ctx: RunMutationCtx,
|
|
292
|
+
args: NamespaceSelection &
|
|
293
|
+
EntryArgs<FitlerSchemas, EntryMetadata> & {
|
|
294
|
+
/**
|
|
295
|
+
* A function that splits the entry into chunks and embeds them.
|
|
296
|
+
* This should be passed as internal.foo.myChunkerAction
|
|
297
|
+
* e.g.
|
|
298
|
+
* ```ts
|
|
299
|
+
* export const myChunkerAction = rag.defineChunkerAction();
|
|
300
|
+
*
|
|
301
|
+
* // in your mutation
|
|
302
|
+
* const entryId = await rag.addAsync(ctx, {
|
|
303
|
+
* key: "myfile.txt",
|
|
304
|
+
* namespace: "my-namespace",
|
|
305
|
+
* chunker: internal.foo.myChunkerAction,
|
|
306
|
+
* });
|
|
307
|
+
*/
|
|
308
|
+
chunkerAction: ChunkerAction;
|
|
309
|
+
}
|
|
310
|
+
): Promise<{ entryId: EntryId; status: "ready" | "pending" }> {
|
|
311
|
+
let namespaceId: NamespaceId;
|
|
312
|
+
if ("namespaceId" in args) {
|
|
313
|
+
namespaceId = args.namespaceId;
|
|
314
|
+
} else {
|
|
315
|
+
const namespace = await this.getOrCreateNamespace(ctx, {
|
|
316
|
+
namespace: args.namespace,
|
|
317
|
+
status: "ready",
|
|
318
|
+
});
|
|
319
|
+
namespaceId = namespace.namespaceId;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
validateAddFilterValues(args.filterValues, this.options.filterNames);
|
|
323
|
+
|
|
324
|
+
const onComplete = args.onComplete
|
|
325
|
+
? await createFunctionHandle(args.onComplete)
|
|
326
|
+
: undefined;
|
|
327
|
+
const chunker = await createFunctionHandle(args.chunkerAction);
|
|
328
|
+
|
|
329
|
+
const { entryId, status } = await ctx.runMutation(
|
|
330
|
+
this.component.entries.addAsync,
|
|
331
|
+
{
|
|
332
|
+
entry: {
|
|
333
|
+
key: args.key,
|
|
334
|
+
namespaceId,
|
|
335
|
+
title: args.title,
|
|
336
|
+
metadata: args.metadata,
|
|
337
|
+
filterValues: args.filterValues ?? [],
|
|
338
|
+
importance: args.importance ?? 1,
|
|
339
|
+
contentHash: args.contentHash,
|
|
340
|
+
},
|
|
341
|
+
onComplete,
|
|
342
|
+
chunker,
|
|
343
|
+
}
|
|
344
|
+
);
|
|
345
|
+
return { entryId: entryId as EntryId, status };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Search for entries in a namespace with configurable filters.
|
|
350
|
+
* You can provide a query string or target embedding, as well as search
|
|
351
|
+
* parameters to filter and constrain the results.
|
|
352
|
+
*/
|
|
353
|
+
async search(
|
|
354
|
+
ctx: RunActionCtx,
|
|
355
|
+
args: {
|
|
356
|
+
/**
|
|
357
|
+
* The namespace to search in. e.g. a userId if entries are per-user.
|
|
358
|
+
* Note: it will only match entries in the namespace that match the
|
|
359
|
+
* modelId, embedding dimension, and filterNames of the RAG instance.
|
|
360
|
+
*/
|
|
361
|
+
namespace: string;
|
|
362
|
+
/**
|
|
363
|
+
* The query to search for. Optional if embedding is provided.
|
|
364
|
+
*/
|
|
365
|
+
query?: string;
|
|
366
|
+
} & SearchOptions<FitlerSchemas>
|
|
367
|
+
): Promise<{
|
|
368
|
+
results: SearchResult[];
|
|
369
|
+
text: string;
|
|
370
|
+
entries: SearchEntry<FitlerSchemas, EntryMetadata>[];
|
|
371
|
+
}> {
|
|
372
|
+
const {
|
|
373
|
+
namespace,
|
|
374
|
+
filters = [],
|
|
375
|
+
limit = DEFAULT_SEARCH_LIMIT,
|
|
376
|
+
chunkContext = { before: 0, after: 0 },
|
|
377
|
+
vectorScoreThreshold,
|
|
378
|
+
} = args;
|
|
379
|
+
let embedding = args.embedding;
|
|
380
|
+
if (!embedding) {
|
|
381
|
+
const embedResult = await embed({
|
|
382
|
+
model: this.options.textEmbeddingModel,
|
|
383
|
+
value: args.query,
|
|
384
|
+
});
|
|
385
|
+
embedding = embedResult.embedding;
|
|
386
|
+
}
|
|
387
|
+
const { results, entries } = await ctx.runAction(
|
|
388
|
+
this.component.search.search,
|
|
389
|
+
{
|
|
390
|
+
embedding,
|
|
391
|
+
namespace,
|
|
392
|
+
modelId: this.options.textEmbeddingModel.modelId,
|
|
393
|
+
filters,
|
|
394
|
+
limit,
|
|
395
|
+
vectorScoreThreshold,
|
|
396
|
+
chunkContext,
|
|
397
|
+
}
|
|
398
|
+
);
|
|
399
|
+
const entriesWithTexts = entries.map((e) => {
|
|
400
|
+
const ranges = results
|
|
401
|
+
.filter((r) => r.entryId === e.entryId)
|
|
402
|
+
.sort((a, b) => a.startOrder - b.startOrder);
|
|
403
|
+
let text = "";
|
|
404
|
+
let previousEnd = 0;
|
|
405
|
+
for (const range of ranges) {
|
|
406
|
+
if (previousEnd !== 0) {
|
|
407
|
+
if (range.startOrder !== previousEnd) {
|
|
408
|
+
text += "\n...\n";
|
|
409
|
+
} else {
|
|
410
|
+
text += "\n";
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
text += range.content.map((c) => c.text).join("\n");
|
|
414
|
+
previousEnd = range.startOrder + range.content.length;
|
|
415
|
+
}
|
|
416
|
+
return { ...e, text } as SearchEntry<FitlerSchemas, EntryMetadata>;
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
return {
|
|
420
|
+
results: results as SearchResult[],
|
|
421
|
+
text: entriesWithTexts
|
|
422
|
+
.map((e) => (e.title ? `# ${e.title}:\n${e.text}` : e.text))
|
|
423
|
+
.join(`\n---\n`),
|
|
424
|
+
entries: entriesWithTexts,
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Generate text based on Retrieval-Augmented Generation.
|
|
430
|
+
*
|
|
431
|
+
* This will search for entries in the namespace based on the prompt and use
|
|
432
|
+
* the results as context to generate text, using the search options args.
|
|
433
|
+
* You can override the default "system" message to provide instructions on
|
|
434
|
+
* using the context and answering in the appropriate style.
|
|
435
|
+
* You can provide "messages" in addition to the prompt to provide
|
|
436
|
+
* extra context / conversation history.
|
|
437
|
+
*/
|
|
438
|
+
async generateText(
|
|
439
|
+
ctx: RunActionCtx,
|
|
440
|
+
args: {
|
|
441
|
+
/**
|
|
442
|
+
* The search options to use for context search, including the namespace.
|
|
443
|
+
*/
|
|
444
|
+
search: SearchOptions<FitlerSchemas> & {
|
|
445
|
+
/**
|
|
446
|
+
* The namespace to search in. e.g. a userId if entries are per-user.
|
|
447
|
+
*/
|
|
448
|
+
namespace: string;
|
|
449
|
+
};
|
|
450
|
+
/**
|
|
451
|
+
* Required. The prompt to use for context search, as well as the final
|
|
452
|
+
* message to the LLM when generating text.
|
|
453
|
+
* Can be used along with "messages"
|
|
454
|
+
*/
|
|
455
|
+
prompt: string;
|
|
456
|
+
/**
|
|
457
|
+
* Additional messages to add to the context. Can be provided in addition
|
|
458
|
+
* to the prompt, in which case it will precede the prompt.
|
|
459
|
+
*/
|
|
460
|
+
messages?: CoreMessage[];
|
|
461
|
+
} & Parameters<typeof generateText>[0]
|
|
462
|
+
): Promise<
|
|
463
|
+
Awaited<ReturnType<typeof generateText>> & {
|
|
464
|
+
context: {
|
|
465
|
+
results: SearchResult[];
|
|
466
|
+
text: string;
|
|
467
|
+
entries: SearchEntry<FitlerSchemas, EntryMetadata>[];
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
> {
|
|
471
|
+
const {
|
|
472
|
+
search: { namespace, ...searchOpts },
|
|
473
|
+
prompt,
|
|
474
|
+
...aiSdkOpts
|
|
475
|
+
} = args;
|
|
476
|
+
const context = await this.search(ctx, {
|
|
477
|
+
namespace,
|
|
478
|
+
query: prompt,
|
|
479
|
+
...searchOpts,
|
|
480
|
+
});
|
|
481
|
+
let contextHeader =
|
|
482
|
+
"Use the following context to respond to the user's question:\n";
|
|
483
|
+
let contextContents = context.text;
|
|
484
|
+
let contextFooter = "\n--------------------------------\n";
|
|
485
|
+
let userQuestionHeader = "";
|
|
486
|
+
let userQuestionFooter = "";
|
|
487
|
+
let userPrompt = prompt;
|
|
488
|
+
switch (aiSdkOpts.model.provider) {
|
|
489
|
+
case "openai":
|
|
490
|
+
userQuestionHeader = '**User question:**\n"""';
|
|
491
|
+
userQuestionFooter = '"""';
|
|
492
|
+
break;
|
|
493
|
+
case "meta":
|
|
494
|
+
userQuestionHeader = "**User question:**\n";
|
|
495
|
+
break;
|
|
496
|
+
case "google":
|
|
497
|
+
userQuestionHeader = "<question>";
|
|
498
|
+
userQuestionFooter = "</question>";
|
|
499
|
+
// fallthrough
|
|
500
|
+
case "anthropic":
|
|
501
|
+
contextHeader = "<context>";
|
|
502
|
+
contextContents = context.entries
|
|
503
|
+
.map((e) =>
|
|
504
|
+
e.title
|
|
505
|
+
? `<document title="${e.title}">${e.text}</document>`
|
|
506
|
+
: `<document>${e.text}</document>`
|
|
507
|
+
)
|
|
508
|
+
.join("\n");
|
|
509
|
+
contextFooter = "</context>";
|
|
510
|
+
userPrompt = prompt.replace(/</g, "<").replace(/>/g, ">");
|
|
511
|
+
break;
|
|
512
|
+
default:
|
|
513
|
+
}
|
|
514
|
+
const promptWithContext = [
|
|
515
|
+
contextHeader,
|
|
516
|
+
contextContents,
|
|
517
|
+
contextFooter,
|
|
518
|
+
"\n",
|
|
519
|
+
userQuestionHeader,
|
|
520
|
+
userPrompt,
|
|
521
|
+
userQuestionFooter,
|
|
522
|
+
]
|
|
523
|
+
.join("\n")
|
|
524
|
+
.trim();
|
|
525
|
+
|
|
526
|
+
const result = (await generateText({
|
|
527
|
+
system:
|
|
528
|
+
"You use the context provided only to produce a response. Do not preface the response with acknowledgement of the context.",
|
|
529
|
+
...aiSdkOpts,
|
|
530
|
+
messages: [
|
|
531
|
+
...(args.messages ?? []),
|
|
532
|
+
{
|
|
533
|
+
role: "user",
|
|
534
|
+
content: promptWithContext,
|
|
535
|
+
},
|
|
536
|
+
],
|
|
537
|
+
})) as Awaited<ReturnType<typeof generateText>> & {
|
|
538
|
+
context: {
|
|
539
|
+
results: SearchResult[];
|
|
540
|
+
text: string;
|
|
541
|
+
entries: SearchEntry<FitlerSchemas, EntryMetadata>[];
|
|
542
|
+
};
|
|
543
|
+
};
|
|
544
|
+
result.context = context;
|
|
545
|
+
return result;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* List all entries in a namespace.
|
|
550
|
+
*/
|
|
551
|
+
async list(
|
|
552
|
+
ctx: RunQueryCtx,
|
|
553
|
+
args: {
|
|
554
|
+
namespaceId: NamespaceId;
|
|
555
|
+
paginationOpts: PaginationOptions;
|
|
556
|
+
order?: "desc" | "asc";
|
|
557
|
+
status?: Status;
|
|
558
|
+
}
|
|
559
|
+
): Promise<PaginationResult<Entry<FitlerSchemas, EntryMetadata>>> {
|
|
560
|
+
const results = await ctx.runQuery(this.component.entries.list, {
|
|
561
|
+
namespaceId: args.namespaceId,
|
|
562
|
+
paginationOpts: args.paginationOpts,
|
|
563
|
+
order: args.order ?? "asc",
|
|
564
|
+
status: args.status ?? "ready",
|
|
565
|
+
});
|
|
566
|
+
return results as PaginationResult<Entry<FitlerSchemas, EntryMetadata>>;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Get entry metadata by its id.
|
|
571
|
+
*/
|
|
572
|
+
async getEntry(
|
|
573
|
+
ctx: RunQueryCtx,
|
|
574
|
+
args: {
|
|
575
|
+
entryId: EntryId;
|
|
576
|
+
}
|
|
577
|
+
): Promise<Entry<FitlerSchemas, EntryMetadata> | null> {
|
|
578
|
+
const entry = await ctx.runQuery(this.component.entries.get, {
|
|
579
|
+
entryId: args.entryId,
|
|
580
|
+
});
|
|
581
|
+
return entry as Entry<FitlerSchemas, EntryMetadata> | null;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* Find an existing entry by its content hash, which you can use to copy
|
|
586
|
+
* new results into a new entry when migrating, or avoiding duplicating work
|
|
587
|
+
* when updating content.
|
|
588
|
+
*/
|
|
589
|
+
async findExistingEntryByContentHash(
|
|
590
|
+
ctx: RunQueryCtx,
|
|
591
|
+
args: {
|
|
592
|
+
namespace: string;
|
|
593
|
+
key: string;
|
|
594
|
+
/** The hash of the entry contents to try to match. */
|
|
595
|
+
contentHash: string;
|
|
596
|
+
}
|
|
597
|
+
): Promise<Entry<FitlerSchemas, EntryMetadata> | null> {
|
|
598
|
+
const entry = await ctx.runQuery(this.component.entries.findByContentHash, {
|
|
599
|
+
namespace: args.namespace,
|
|
600
|
+
dimension: this.options.embeddingDimension,
|
|
601
|
+
filterNames: this.options.filterNames ?? [],
|
|
602
|
+
modelId: this.options.textEmbeddingModel.modelId,
|
|
603
|
+
key: args.key,
|
|
604
|
+
contentHash: args.contentHash,
|
|
605
|
+
});
|
|
606
|
+
return entry as Entry<FitlerSchemas, EntryMetadata> | null;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Get a namespace that matches the modelId, embedding dimension, and
|
|
611
|
+
* filterNames of the RAG instance. If it doesn't exist, it will be created.
|
|
612
|
+
*/
|
|
613
|
+
async getOrCreateNamespace(
|
|
614
|
+
ctx: RunMutationCtx,
|
|
615
|
+
args: {
|
|
616
|
+
/**
|
|
617
|
+
* The namespace to get or create. e.g. a userId if entries are per-user.
|
|
618
|
+
*/
|
|
619
|
+
namespace: string;
|
|
620
|
+
/**
|
|
621
|
+
* If it isn't in existence, what the new namespace status should be.
|
|
622
|
+
*/
|
|
623
|
+
status?: "pending" | "ready";
|
|
624
|
+
/**
|
|
625
|
+
* This will be called when then namespace leaves the "pending" state.
|
|
626
|
+
* Either if the namespace is created or if the namespace is replaced
|
|
627
|
+
* along the way.
|
|
628
|
+
*/
|
|
629
|
+
onComplete?: OnCompleteNamespace;
|
|
630
|
+
}
|
|
631
|
+
): Promise<{
|
|
632
|
+
namespaceId: NamespaceId;
|
|
633
|
+
status: "pending" | "ready";
|
|
634
|
+
}> {
|
|
635
|
+
const onComplete = args.onComplete
|
|
636
|
+
? await createFunctionHandle(args.onComplete)
|
|
637
|
+
: undefined;
|
|
638
|
+
assert(
|
|
639
|
+
!onComplete || args.status === "pending",
|
|
640
|
+
"You can only supply an onComplete handler for pending namespaces"
|
|
641
|
+
);
|
|
642
|
+
const { namespaceId, status } = await ctx.runMutation(
|
|
643
|
+
this.component.namespaces.getOrCreate,
|
|
644
|
+
{
|
|
645
|
+
namespace: args.namespace,
|
|
646
|
+
status: args.status ?? "ready",
|
|
647
|
+
onComplete,
|
|
648
|
+
modelId: this.options.textEmbeddingModel.modelId,
|
|
649
|
+
dimension: this.options.embeddingDimension,
|
|
650
|
+
filterNames: this.options.filterNames ?? [],
|
|
651
|
+
}
|
|
652
|
+
);
|
|
653
|
+
return { namespaceId: namespaceId as NamespaceId, status };
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* Get a namespace that matches the modelId, embedding dimension, and
|
|
658
|
+
* filterNames of the RAG instance. If it doesn't exist, it will return null.
|
|
659
|
+
*/
|
|
660
|
+
async getNamespace(
|
|
661
|
+
ctx: RunQueryCtx,
|
|
662
|
+
args: {
|
|
663
|
+
namespace: string;
|
|
664
|
+
}
|
|
665
|
+
): Promise<Namespace | null> {
|
|
666
|
+
return ctx.runQuery(this.component.namespaces.get, {
|
|
667
|
+
namespace: args.namespace,
|
|
668
|
+
modelId: this.options.textEmbeddingModel.modelId,
|
|
669
|
+
dimension: this.options.embeddingDimension,
|
|
670
|
+
filterNames: this.options.filterNames ?? [],
|
|
671
|
+
}) as Promise<Namespace | null>;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* List all chunks for an entry, paginated.
|
|
676
|
+
*/
|
|
677
|
+
async listChunks(
|
|
678
|
+
ctx: RunQueryCtx,
|
|
679
|
+
args: {
|
|
680
|
+
paginationOpts: PaginationOptions;
|
|
681
|
+
entryId: EntryId;
|
|
682
|
+
}
|
|
683
|
+
): Promise<PaginationResult<Chunk>> {
|
|
684
|
+
return ctx.runQuery(this.component.chunks.list, {
|
|
685
|
+
entryId: args.entryId,
|
|
686
|
+
paginationOpts: args.paginationOpts,
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* Delete an entry and all its chunks.
|
|
692
|
+
*/
|
|
693
|
+
async delete(ctx: RunMutationCtx, args: { entryId: EntryId }) {
|
|
694
|
+
await ctx.runMutation(this.component.entries.deleteAsync, {
|
|
695
|
+
entryId: args.entryId,
|
|
696
|
+
startOrder: 0,
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* Define a function that can be provided to the `onComplete` parameter of
|
|
702
|
+
* `add` or `addAsync` like:
|
|
703
|
+
* ```ts
|
|
704
|
+
* const onComplete = rag.defineOnComplete(async (ctx, args) => {
|
|
705
|
+
* // ...
|
|
706
|
+
* });
|
|
707
|
+
*
|
|
708
|
+
* // in your mutation
|
|
709
|
+
* await rag.add(ctx, {
|
|
710
|
+
* namespace: "my-namespace",
|
|
711
|
+
* onComplete: internal.foo.onComplete,
|
|
712
|
+
* });
|
|
713
|
+
* ```
|
|
714
|
+
* It will be called when the entry is no longer "pending".
|
|
715
|
+
* This is usually when it's "ready" but it can be "replaced" if a newer
|
|
716
|
+
* entry is ready before this one.
|
|
717
|
+
*/
|
|
718
|
+
defineOnComplete<DataModel extends GenericDataModel>(
|
|
719
|
+
fn: (
|
|
720
|
+
ctx: GenericMutationCtx<DataModel>,
|
|
721
|
+
args: FunctionArgs<OnComplete<FitlerSchemas, EntryMetadata>>
|
|
722
|
+
) => Promise<void>
|
|
723
|
+
): RegisteredMutation<
|
|
724
|
+
"internal",
|
|
725
|
+
FunctionArgs<OnComplete<FitlerSchemas, EntryMetadata>>,
|
|
726
|
+
null
|
|
727
|
+
> {
|
|
728
|
+
return internalMutationGeneric({
|
|
729
|
+
args: vOnCompleteArgs,
|
|
730
|
+
handler: fn,
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* Define a function that can be provided to the `chunkerAction` parameter of
|
|
736
|
+
* `addAsync` like:
|
|
737
|
+
* ```ts
|
|
738
|
+
* const chunkerAction = rag.defineChunkerAction(async (ctx, args) => {
|
|
739
|
+
* // ...
|
|
740
|
+
* });
|
|
741
|
+
*
|
|
742
|
+
* // in your mutation
|
|
743
|
+
* const entryId = await rag.addAsync(ctx, {
|
|
744
|
+
* key: "myfile.txt",
|
|
745
|
+
* namespace: "my-namespace",
|
|
746
|
+
* chunkerAction: internal.foo.myChunkerAction,
|
|
747
|
+
* });
|
|
748
|
+
* ```
|
|
749
|
+
* It will be called when the entry is added, or when the entry is replaced
|
|
750
|
+
* along the way.
|
|
751
|
+
*/
|
|
752
|
+
defineChunkerAction<DataModel extends GenericDataModel>(
|
|
753
|
+
fn: (
|
|
754
|
+
ctx: GenericActionCtx<DataModel>,
|
|
755
|
+
args: { namespace: Namespace; entry: Entry<FitlerSchemas, EntryMetadata> }
|
|
756
|
+
) => AsyncIterable<InputChunk> | Promise<{ chunks: InputChunk[] }>
|
|
757
|
+
): RegisteredAction<
|
|
758
|
+
"internal",
|
|
759
|
+
FunctionArgs<ChunkerAction>,
|
|
760
|
+
FunctionReturnType<ChunkerAction>
|
|
761
|
+
> {
|
|
762
|
+
return internalActionGeneric({
|
|
763
|
+
args: vChunkerArgs,
|
|
764
|
+
handler: async (ctx, args) => {
|
|
765
|
+
const { namespace, entry } = args;
|
|
766
|
+
if (namespace.modelId !== this.options.textEmbeddingModel.modelId) {
|
|
767
|
+
console.error(
|
|
768
|
+
`You are using a different embedding model ${this.options.textEmbeddingModel.modelId} for asynchronously ` +
|
|
769
|
+
`generating chunks than the one provided when it was started: ${namespace.modelId}`
|
|
770
|
+
);
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
if (namespace.dimension !== this.options.embeddingDimension) {
|
|
774
|
+
console.error(
|
|
775
|
+
`You are using a different embedding dimension ${this.options.embeddingDimension} for asynchronously ` +
|
|
776
|
+
`generating chunks than the one provided when it was started: ${namespace.dimension}`
|
|
777
|
+
);
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
if (
|
|
781
|
+
!filterNamesContain(
|
|
782
|
+
namespace.filterNames,
|
|
783
|
+
this.options.filterNames ?? []
|
|
784
|
+
)
|
|
785
|
+
) {
|
|
786
|
+
console.error(
|
|
787
|
+
`You are using a different filters (${this.options.filterNames?.join(", ")}) for asynchronously ` +
|
|
788
|
+
`generating chunks than the one provided when it was started: ${namespace.filterNames.join(", ")}`
|
|
789
|
+
);
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
const chunksPromise = fn(ctx, {
|
|
793
|
+
namespace,
|
|
794
|
+
entry: entry as Entry<FitlerSchemas, EntryMetadata>,
|
|
795
|
+
});
|
|
796
|
+
let chunkIterator: AsyncIterable<InputChunk>;
|
|
797
|
+
if (chunksPromise instanceof Promise) {
|
|
798
|
+
const chunks = await chunksPromise;
|
|
799
|
+
chunkIterator = {
|
|
800
|
+
[Symbol.asyncIterator]: async function* () {
|
|
801
|
+
yield* chunks.chunks;
|
|
802
|
+
},
|
|
803
|
+
};
|
|
804
|
+
} else {
|
|
805
|
+
chunkIterator = chunksPromise;
|
|
806
|
+
}
|
|
807
|
+
let batchOrder = 0;
|
|
808
|
+
for await (const batch of batchIterator(
|
|
809
|
+
chunkIterator,
|
|
810
|
+
CHUNK_BATCH_SIZE
|
|
811
|
+
)) {
|
|
812
|
+
const createChunkArgs = await createChunkArgsBatch(
|
|
813
|
+
this.options.textEmbeddingModel,
|
|
814
|
+
batch
|
|
815
|
+
);
|
|
816
|
+
await ctx.runMutation(
|
|
817
|
+
args.insertChunks as FunctionHandle<
|
|
818
|
+
"mutation",
|
|
819
|
+
FunctionArgs<RAGComponent["chunks"]["insert"]>,
|
|
820
|
+
null
|
|
821
|
+
>,
|
|
822
|
+
{
|
|
823
|
+
entryId: entry.entryId,
|
|
824
|
+
startOrder: batchOrder,
|
|
825
|
+
chunks: createChunkArgs,
|
|
826
|
+
}
|
|
827
|
+
);
|
|
828
|
+
batchOrder += createChunkArgs.length;
|
|
829
|
+
}
|
|
830
|
+
},
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
async function* batchIterator<T>(
|
|
836
|
+
iterator: Iterable<T> | AsyncIterable<T>,
|
|
837
|
+
batchSize: number
|
|
838
|
+
): AsyncIterable<T[]> {
|
|
839
|
+
let batch: T[] = [];
|
|
840
|
+
for await (const item of iterator) {
|
|
841
|
+
batch.push(item);
|
|
842
|
+
if (batch.length >= batchSize) {
|
|
843
|
+
yield batch;
|
|
844
|
+
batch = [];
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
if (batch.length > 0) {
|
|
848
|
+
yield batch;
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
function validateAddFilterValues(
|
|
853
|
+
filterValues: NamedFilter[] | undefined,
|
|
854
|
+
filterNames: string[] | undefined
|
|
855
|
+
) {
|
|
856
|
+
if (!filterValues) {
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
if (!filterNames) {
|
|
860
|
+
throw new Error(
|
|
861
|
+
"You must provide filter names to RAG to add entries with filters."
|
|
862
|
+
);
|
|
863
|
+
}
|
|
864
|
+
const seen = new Set<string>();
|
|
865
|
+
for (const filterValue of filterValues) {
|
|
866
|
+
if (seen.has(filterValue.name)) {
|
|
867
|
+
throw new Error(
|
|
868
|
+
`You cannot provide the same filter name twice: ${filterValue.name}.`
|
|
869
|
+
);
|
|
870
|
+
}
|
|
871
|
+
seen.add(filterValue.name);
|
|
872
|
+
}
|
|
873
|
+
for (const filterName of filterNames) {
|
|
874
|
+
if (!seen.has(filterName)) {
|
|
875
|
+
throw new Error(
|
|
876
|
+
`Filter name ${filterName} is not valid (one of ${filterNames.join(", ")}).`
|
|
877
|
+
);
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
function makeBatches<T>(items: T[], batchSize: number): T[][] {
|
|
883
|
+
const batches: T[][] = [];
|
|
884
|
+
for (let i = 0; i < items.length; i += batchSize) {
|
|
885
|
+
batches.push(items.slice(i, i + batchSize));
|
|
886
|
+
}
|
|
887
|
+
return batches;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
async function createChunkArgsBatch(
|
|
891
|
+
embedModel: EmbeddingModelV1<string>,
|
|
892
|
+
chunks: InputChunk[]
|
|
893
|
+
): Promise<CreateChunkArgs[]> {
|
|
894
|
+
const argsMaybeMissingEmbeddings: (Omit<CreateChunkArgs, "embedding"> & {
|
|
895
|
+
embedding?: number[];
|
|
896
|
+
})[] = chunks.map((chunk) => {
|
|
897
|
+
if (typeof chunk === "string") {
|
|
898
|
+
return { content: { text: chunk } };
|
|
899
|
+
} else if ("text" in chunk) {
|
|
900
|
+
const { text, metadata, keywords: searchableText } = chunk;
|
|
901
|
+
return {
|
|
902
|
+
content: { text, metadata },
|
|
903
|
+
embedding: chunk.embedding,
|
|
904
|
+
searchableText,
|
|
905
|
+
};
|
|
906
|
+
} else if ("pageContent" in chunk) {
|
|
907
|
+
const { pageContent: text, metadata, keywords: searchableText } = chunk;
|
|
908
|
+
return {
|
|
909
|
+
content: { text, metadata },
|
|
910
|
+
embedding: chunk.embedding,
|
|
911
|
+
searchableText,
|
|
912
|
+
};
|
|
913
|
+
} else {
|
|
914
|
+
throw new Error("Invalid chunk: " + JSON.stringify(chunk));
|
|
915
|
+
}
|
|
916
|
+
});
|
|
917
|
+
const missingEmbeddingsWithIndex = argsMaybeMissingEmbeddings
|
|
918
|
+
.map((arg, index) =>
|
|
919
|
+
arg.embedding
|
|
920
|
+
? null
|
|
921
|
+
: {
|
|
922
|
+
text: arg.content.text,
|
|
923
|
+
index,
|
|
924
|
+
}
|
|
925
|
+
)
|
|
926
|
+
.filter((b) => b !== null);
|
|
927
|
+
for (const batch of makeBatches(missingEmbeddingsWithIndex, 100)) {
|
|
928
|
+
const { embeddings } = await embedMany({
|
|
929
|
+
model: embedModel,
|
|
930
|
+
values: batch.map((b) => b.text),
|
|
931
|
+
});
|
|
932
|
+
for (const [index, embedding] of embeddings.entries()) {
|
|
933
|
+
argsMaybeMissingEmbeddings[batch[index].index].embedding = embedding;
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
return argsMaybeMissingEmbeddings.filter((a) => {
|
|
937
|
+
if (a.embedding === undefined) {
|
|
938
|
+
throw new Error("Embedding is undefined for chunk " + a.content.text);
|
|
939
|
+
}
|
|
940
|
+
return true;
|
|
941
|
+
}) as CreateChunkArgs[];
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
/**
|
|
945
|
+
* Rank results from multiple results, e.g. from vector search and text search.
|
|
946
|
+
* Uses the "Recriprocal Rank Fusion" algorithm.
|
|
947
|
+
* @param sortedResults The results arrays ordered by most important first.
|
|
948
|
+
*/
|
|
949
|
+
export function hybridRank<T extends string>(
|
|
950
|
+
sortedResults: T[][],
|
|
951
|
+
opts?: {
|
|
952
|
+
/**
|
|
953
|
+
* A constant used to change the bias of the top results in each list vs.
|
|
954
|
+
* results in the middle of multiple lists.
|
|
955
|
+
* A higher k means less of a bias toward the top few results.
|
|
956
|
+
*/
|
|
957
|
+
k: number;
|
|
958
|
+
/**
|
|
959
|
+
* The weights of each sortedResults array.
|
|
960
|
+
* Used to prefer results from one sortedResults array over another.
|
|
961
|
+
*/
|
|
962
|
+
weights: number[];
|
|
963
|
+
/**
|
|
964
|
+
* The cutoff score for a result to be returned.
|
|
965
|
+
*/
|
|
966
|
+
cutoffScore?: number;
|
|
967
|
+
}
|
|
968
|
+
): T[] {
|
|
969
|
+
const k = opts?.k ?? 10;
|
|
970
|
+
const scores: Map<T, number> = new Map();
|
|
971
|
+
for (const [i, results] of sortedResults.entries()) {
|
|
972
|
+
const weight = opts?.weights?.[i] ?? 1;
|
|
973
|
+
for (let j = 0; j < results.length; j++) {
|
|
974
|
+
const key = results[j];
|
|
975
|
+
scores.set(key, (scores.get(key) ?? 0) + weight / (k + j));
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
const sortedScores = Array.from(scores.entries()).sort((a, b) => b[1] - a[1]);
|
|
979
|
+
return sortedScores
|
|
980
|
+
.filter(([_, score]) => score >= (opts?.cutoffScore ?? 0))
|
|
981
|
+
.map(([key]) => key);
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
type MastraChunk = {
|
|
985
|
+
text: string;
|
|
986
|
+
metadata: Record<string, Value>;
|
|
987
|
+
embedding?: Array<number>;
|
|
988
|
+
};
|
|
989
|
+
|
|
990
|
+
type LangChainChunk = {
|
|
991
|
+
id?: string;
|
|
992
|
+
pageContent: string;
|
|
993
|
+
metadata: Record<string, Value>; //{ loc: { lines: { from: number; to: number } } };
|
|
994
|
+
embedding?: Array<number>;
|
|
995
|
+
};
|
|
996
|
+
|
|
997
|
+
export type InputChunk =
|
|
998
|
+
| string
|
|
999
|
+
| ((MastraChunk | LangChainChunk) & {
|
|
1000
|
+
// Space-delimited keywords to text search on.
|
|
1001
|
+
// TODO: implement text search
|
|
1002
|
+
keywords?: string;
|
|
1003
|
+
// In the future we can add per-chunk metadata if it's useful.
|
|
1004
|
+
// importance?: Importance;
|
|
1005
|
+
// filters?: EntryFilterValues<FitlerSchemas>[];
|
|
1006
|
+
});
|
|
1007
|
+
|
|
1008
|
+
type FilterNames<FiltersSchemas extends Record<string, Value>> =
|
|
1009
|
+
(keyof FiltersSchemas & string)[];
|
|
1010
|
+
|
|
1011
|
+
type NamespaceSelection =
|
|
1012
|
+
| {
|
|
1013
|
+
/**
|
|
1014
|
+
* A namespace is an isolated search space - no search can access entities
|
|
1015
|
+
* in other namespaces. Often this is used to segment user documents from
|
|
1016
|
+
* each other, but can be an arbitrary delineation. All filters apply
|
|
1017
|
+
* within a namespace.
|
|
1018
|
+
*/
|
|
1019
|
+
namespace: string;
|
|
1020
|
+
}
|
|
1021
|
+
| {
|
|
1022
|
+
/**
|
|
1023
|
+
* The namespaceId, which is returned when creating a namespace
|
|
1024
|
+
* or looking it up.
|
|
1025
|
+
* There can be multiple namespaceIds for the same namespace, e.g.
|
|
1026
|
+
* one for each modelId, embedding dimension, and filterNames.
|
|
1027
|
+
* Each of them have a separate "status" and only one is ever "ready" for
|
|
1028
|
+
* any given "namespace" (e.g. a userId).
|
|
1029
|
+
*/
|
|
1030
|
+
namespaceId: NamespaceId;
|
|
1031
|
+
};
|
|
1032
|
+
|
|
1033
|
+
type EntryArgs<
|
|
1034
|
+
FitlerSchemas extends Record<string, Value>,
|
|
1035
|
+
EntryMetadata extends Record<string, Value>,
|
|
1036
|
+
> = {
|
|
1037
|
+
/**
|
|
1038
|
+
* This key allows replacing an existing entry by key.
|
|
1039
|
+
* Within a namespace, there will only be one "ready" entry per key.
|
|
1040
|
+
* When adding a new one, it will start as "pending" and after all
|
|
1041
|
+
* chunks are added, it will be promoted to "ready".
|
|
1042
|
+
*/
|
|
1043
|
+
key?: string | undefined;
|
|
1044
|
+
/**
|
|
1045
|
+
* The title of the entry. Used for default prompting to contextualize
|
|
1046
|
+
* the entry results. Also may be used for keyword search in the future.
|
|
1047
|
+
*/
|
|
1048
|
+
title?: string;
|
|
1049
|
+
/**
|
|
1050
|
+
* Metadata about the entry that is not indexed or filtered or searched.
|
|
1051
|
+
* Provided as a convenience to store associated information, such as
|
|
1052
|
+
* the storageId or url to the source material.
|
|
1053
|
+
*/
|
|
1054
|
+
metadata?: EntryMetadata;
|
|
1055
|
+
/**
|
|
1056
|
+
* Filters to apply to the entry. These can be OR'd together in search.
|
|
1057
|
+
* To represent AND logic, your filter can be an object or array with
|
|
1058
|
+
* multiple values. e.g. saving the result with:
|
|
1059
|
+
* `{ name: "categoryAndPriority", value: ["articles", "high"] }`
|
|
1060
|
+
* and searching with the same value will return entries that match that
|
|
1061
|
+
* value exactly.
|
|
1062
|
+
*/
|
|
1063
|
+
filterValues?: EntryFilterValues<FitlerSchemas>[];
|
|
1064
|
+
/**
|
|
1065
|
+
* The importance of the entry. This is used to scale the vector search
|
|
1066
|
+
* score of each chunk.
|
|
1067
|
+
*/
|
|
1068
|
+
importance?: Importance;
|
|
1069
|
+
/**
|
|
1070
|
+
* The hash of the entry contents. This is used to deduplicate entries.
|
|
1071
|
+
* You can look up existing entries by content hash within a namespace.
|
|
1072
|
+
* It will also return an existing entry if you add an entry with the
|
|
1073
|
+
* same content hash.
|
|
1074
|
+
*/
|
|
1075
|
+
contentHash?: string;
|
|
1076
|
+
/**
|
|
1077
|
+
* A function that is called when the entry is added.
|
|
1078
|
+
*/
|
|
1079
|
+
onComplete?: OnComplete;
|
|
1080
|
+
};
|
|
1081
|
+
|
|
1082
|
+
type SearchOptions<FitlerSchemas extends Record<string, Value>> = {
|
|
1083
|
+
/**
|
|
1084
|
+
* The embedding to search for. If provided, it will be used instead
|
|
1085
|
+
* of the query for vector search.
|
|
1086
|
+
*/
|
|
1087
|
+
embedding?: Array<number>;
|
|
1088
|
+
/**
|
|
1089
|
+
* Filters to apply to the search. These are OR'd together. To represent
|
|
1090
|
+
* AND logic, your filter can be an object or array with multiple values.
|
|
1091
|
+
* e.g. `[{ category: "articles" }, { priority: "high" }]` will return
|
|
1092
|
+
* entries that have "articles" category OR "high" priority.
|
|
1093
|
+
* `[{ category_priority: ["articles", "high"] }]` will return
|
|
1094
|
+
* entries that have "articles" category AND "high" priority.
|
|
1095
|
+
* This requires inserting the entries with these filter values exactly.
|
|
1096
|
+
* e.g. if you insert a entry with
|
|
1097
|
+
* `{ team_user: { team: "team1", user: "user1" } }`, it will not match
|
|
1098
|
+
* `{ team_user: { team: "team1" } }` but it will match
|
|
1099
|
+
*/
|
|
1100
|
+
filters?: EntryFilterValues<FitlerSchemas>[];
|
|
1101
|
+
/**
|
|
1102
|
+
* The maximum number of messages to fetch. Default is 10.
|
|
1103
|
+
* This is the number *before* the chunkContext is applied.
|
|
1104
|
+
* e.g. { before: 2, after: 1 } means 4x the limit is returned.
|
|
1105
|
+
*/
|
|
1106
|
+
limit?: number;
|
|
1107
|
+
/**
|
|
1108
|
+
* What chunks around the search results to include.
|
|
1109
|
+
* Default: { before: 0, after: 0 }
|
|
1110
|
+
* e.g. { before: 2, after: 1 } means 2 chunks before + 1 chunk after.
|
|
1111
|
+
* If `chunk4` was the only result, the results returned would be:
|
|
1112
|
+
* `[{ content: [chunk2, chunk3, chunk4, chunk5], score, ... }]`
|
|
1113
|
+
* The results don't overlap, and bias toward giving "before" context.
|
|
1114
|
+
* So if `chunk7` was also a result, the results returned would be:
|
|
1115
|
+
* `[
|
|
1116
|
+
* { content: [chunk2, chunk3, chunk4], score, ... }
|
|
1117
|
+
* { content: [chunk5, chunk6, chunk7, chunk8], score, ... },
|
|
1118
|
+
* ]`
|
|
1119
|
+
*/
|
|
1120
|
+
chunkContext?: { before: number; after: number };
|
|
1121
|
+
/**
|
|
1122
|
+
* The minimum score to return a result.
|
|
1123
|
+
*/
|
|
1124
|
+
vectorScoreThreshold?: number;
|
|
1125
|
+
};
|