@lunora/ai 1.0.0-alpha.2 → 1.0.0-alpha.21
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.md +6 -0
- package/README.md +2 -0
- package/dist/index.d.mts +26 -28
- package/dist/index.d.ts +26 -28
- package/dist/index.mjs +2 -2
- package/dist/packem_shared/bm25LexicalStore-BQzWLMqX.mjs +117 -0
- package/dist/packem_shared/contentHash-Cgz5KRGD.mjs +92 -0
- package/dist/packem_shared/{createAi-CaLhvwAd.mjs → createAi-CYxQuvEL.mjs} +14 -8
- package/dist/packem_shared/defineRag-CQ3pnKUh.mjs +471 -0
- package/dist/packem_shared/fixedWindowChunks-J-WfQDw9.mjs +26 -0
- package/dist/packem_shared/hybridRank-DPC9c2ON.mjs +20 -0
- package/dist/packem_shared/types.d-BXCiRv1x.d.mts +109 -0
- package/dist/packem_shared/types.d-BXCiRv1x.d.ts +109 -0
- package/dist/rag/index.d.mts +550 -0
- package/dist/rag/index.d.ts +550 -0
- package/dist/rag/index.mjs +5 -0
- package/package.json +12 -5
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
import { LunoraError } from '@lunora/errors';
|
|
2
|
+
import { tool, jsonSchema, embed } from 'ai';
|
|
3
|
+
import fixedWindowChunks from './fixedWindowChunks-J-WfQDw9.mjs';
|
|
4
|
+
import hybridRank from './hybridRank-DPC9c2ON.mjs';
|
|
5
|
+
|
|
6
|
+
const INDEX_CONCURRENCY = 8;
|
|
7
|
+
const concurrentMap = async (items, limit, function_) => {
|
|
8
|
+
if (!Number.isInteger(limit) || limit < 1) {
|
|
9
|
+
throw new RangeError("concurrentMap: `limit` must be a positive integer");
|
|
10
|
+
}
|
|
11
|
+
if (items.length === 0) {
|
|
12
|
+
return [];
|
|
13
|
+
}
|
|
14
|
+
const effectiveLimit = Math.max(1, Math.min(limit, items.length));
|
|
15
|
+
const results = Array.from({ length: items.length });
|
|
16
|
+
let cursor = 0;
|
|
17
|
+
let failed = false;
|
|
18
|
+
let firstError;
|
|
19
|
+
const worker = async () => {
|
|
20
|
+
for (; ; ) {
|
|
21
|
+
if (failed) {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
const index = cursor;
|
|
25
|
+
cursor += 1;
|
|
26
|
+
if (index >= items.length) {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
results[index] = await function_(items[index], index);
|
|
31
|
+
} catch (error) {
|
|
32
|
+
if (!failed) {
|
|
33
|
+
failed = true;
|
|
34
|
+
firstError = error;
|
|
35
|
+
}
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
const workers = Array.from({ length: effectiveLimit }, () => worker());
|
|
41
|
+
await Promise.all(workers);
|
|
42
|
+
if (failed) {
|
|
43
|
+
throw firstError;
|
|
44
|
+
}
|
|
45
|
+
return results;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const DEFAULT_CHUNK_SIZE = 1e3;
|
|
49
|
+
const DEFAULT_CHUNK_OVERLAP = 200;
|
|
50
|
+
const DEFAULT_TOP_K = 5;
|
|
51
|
+
const MAX_TOP_K_FULL_METADATA = 20;
|
|
52
|
+
const MAX_TOP_K = 100;
|
|
53
|
+
const CHUNK_INDEX_KEY = "__ragChunk";
|
|
54
|
+
const SOURCE_KEY = "__ragSource";
|
|
55
|
+
const TEXT_KEY = "__ragText";
|
|
56
|
+
const HASH_KEY = "__ragHash";
|
|
57
|
+
const COUNT_KEY = "__ragChunks";
|
|
58
|
+
const IMPORTANCE_KEY = "__ragImportance";
|
|
59
|
+
const MODEL_KEY = "__ragModel";
|
|
60
|
+
const INTERNAL_KEYS = /* @__PURE__ */ new Set([CHUNK_INDEX_KEY, COUNT_KEY, HASH_KEY, IMPORTANCE_KEY, MODEL_KEY, SOURCE_KEY, TEXT_KEY]);
|
|
61
|
+
const MODEL_VERSION_PATTERN = /^[\w.-]{1,40}$/;
|
|
62
|
+
const namespacePrefix = (namespace) => namespace === void 0 ? "" : `${encodeURIComponent(namespace)}#`;
|
|
63
|
+
const chunkVectorId = (namespace, sourceId, chunkIndex) => `${namespacePrefix(namespace)}${sourceId}#${String(chunkIndex)}`;
|
|
64
|
+
const parseChunkVectorId = (id, namespace) => {
|
|
65
|
+
const prefix = namespacePrefix(namespace);
|
|
66
|
+
const body = prefix !== "" && id.startsWith(prefix) ? id.slice(prefix.length) : id;
|
|
67
|
+
const separator = body.lastIndexOf("#");
|
|
68
|
+
const chunkIndex = separator === -1 ? Number.NaN : Number(body.slice(separator + 1));
|
|
69
|
+
if (separator === -1 || !Number.isInteger(chunkIndex) || chunkIndex < 0) {
|
|
70
|
+
return { chunkIndex: 0, sourceId: body };
|
|
71
|
+
}
|
|
72
|
+
return { chunkIndex, sourceId: body.slice(0, separator) };
|
|
73
|
+
};
|
|
74
|
+
const sha256Hex = async (text) => {
|
|
75
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
|
|
76
|
+
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
77
|
+
};
|
|
78
|
+
const userMetadataOf = (metadata) => {
|
|
79
|
+
if (!metadata) {
|
|
80
|
+
return void 0;
|
|
81
|
+
}
|
|
82
|
+
const entries = Object.entries(metadata).filter(([key]) => !INTERNAL_KEYS.has(key));
|
|
83
|
+
return entries.length > 0 ? Object.fromEntries(entries) : void 0;
|
|
84
|
+
};
|
|
85
|
+
const sharedNamespaceWarned = /* @__PURE__ */ new Set();
|
|
86
|
+
const warnSharedNamespace = (indexName) => {
|
|
87
|
+
if (sharedNamespaceWarned.has(indexName)) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
sharedNamespaceWarned.add(indexName);
|
|
91
|
+
console.warn(
|
|
92
|
+
`[@lunora/ai/rag] index "${indexName}" is used without a namespace — in a multi-tenant/sharded
|
|
93
|
+
app this shares one tenant's chunks (text included) with every other tenant, since
|
|
94
|
+
Vectorize indexes are account-global. Pass \`namespace\` (the shard/tenant key) on both
|
|
95
|
+
index() and retrieve(). Single-tenant apps suppress this via { allowSharedNamespace: true }.`
|
|
96
|
+
);
|
|
97
|
+
};
|
|
98
|
+
const assembleContext = (chunks) => chunks.map((chunk) => `[source:${chunk.sourceId}#${String(chunk.chunkIndex)}]
|
|
99
|
+
${chunk.text}`).join("\n\n");
|
|
100
|
+
const resolveEmbeddingModel = (input, ai) => {
|
|
101
|
+
if (typeof input === "object") {
|
|
102
|
+
return input;
|
|
103
|
+
}
|
|
104
|
+
if (ai === void 0) {
|
|
105
|
+
throw new LunoraError(
|
|
106
|
+
"INTERNAL",
|
|
107
|
+
"@lunora/ai/rag: `embeddingModel` is a Workers AI model id (or omitted) but the bound context has no `ai` (env.AI). Pass an AI SDK EmbeddingModel object to embed without Workers AI, or bind a context whose `ctx.ai` is wired."
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
return ai.embeddingModel(input);
|
|
111
|
+
};
|
|
112
|
+
const modelIdOf = (model) => {
|
|
113
|
+
const id = model.modelId;
|
|
114
|
+
return typeof id === "string" && id.length > 0 ? id : void 0;
|
|
115
|
+
};
|
|
116
|
+
const embedCostOf = (providerMetadata) => {
|
|
117
|
+
if (typeof providerMetadata !== "object" || providerMetadata === null) {
|
|
118
|
+
return void 0;
|
|
119
|
+
}
|
|
120
|
+
for (const bag of Object.values(providerMetadata)) {
|
|
121
|
+
if (typeof bag === "object" && bag !== null) {
|
|
122
|
+
const { cost } = bag;
|
|
123
|
+
if (typeof cost === "number" && Number.isFinite(cost)) {
|
|
124
|
+
return cost;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return void 0;
|
|
129
|
+
};
|
|
130
|
+
const defineRag = (config) => {
|
|
131
|
+
if (typeof config.index !== "string" || config.index.length === 0) {
|
|
132
|
+
throw new LunoraError("BAD_REQUEST", "@lunora/ai/rag: `index` must be a non-empty Vectorize index name");
|
|
133
|
+
}
|
|
134
|
+
const chunkSize = config.chunkSize ?? DEFAULT_CHUNK_SIZE;
|
|
135
|
+
const chunkOverlap = config.chunkOverlap ?? DEFAULT_CHUNK_OVERLAP;
|
|
136
|
+
if (!Number.isInteger(chunkSize) || chunkSize < 1) {
|
|
137
|
+
throw new LunoraError("BAD_REQUEST", "@lunora/ai/rag: `chunkSize` must be a positive integer");
|
|
138
|
+
}
|
|
139
|
+
if (!Number.isInteger(chunkOverlap) || chunkOverlap < 0 || chunkOverlap >= chunkSize) {
|
|
140
|
+
throw new LunoraError("BAD_REQUEST", "@lunora/ai/rag: `chunkOverlap` must be a non-negative integer smaller than `chunkSize`");
|
|
141
|
+
}
|
|
142
|
+
const defaultTopK = config.topK ?? DEFAULT_TOP_K;
|
|
143
|
+
if (!Number.isInteger(defaultTopK) || defaultTopK < 1) {
|
|
144
|
+
throw new LunoraError("BAD_REQUEST", "@lunora/ai/rag: `topK` must be a positive integer");
|
|
145
|
+
}
|
|
146
|
+
if (config.embeddingModelVersion !== void 0 && !MODEL_VERSION_PATTERN.test(config.embeddingModelVersion)) {
|
|
147
|
+
throw new LunoraError(
|
|
148
|
+
"BAD_REQUEST",
|
|
149
|
+
'@lunora/ai/rag: `embeddingModelVersion` must match /^[A-Za-z0-9._-]{1,40}$/ (a short, stable tag like "bge-v1.5")'
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
const splitter = config.chunk ?? ((text) => fixedWindowChunks(text, chunkSize, chunkOverlap));
|
|
153
|
+
const { textStore } = config;
|
|
154
|
+
const topKCeiling = textStore ? MAX_TOP_K : MAX_TOP_K_FULL_METADATA;
|
|
155
|
+
const modelTag = config.embeddingModelVersion;
|
|
156
|
+
const withModelTag = (namespace) => {
|
|
157
|
+
if (modelTag === void 0) {
|
|
158
|
+
return namespace;
|
|
159
|
+
}
|
|
160
|
+
return namespace === void 0 ? modelTag : `${modelTag}::${namespace}`;
|
|
161
|
+
};
|
|
162
|
+
return (context) => {
|
|
163
|
+
let model;
|
|
164
|
+
const tracer = typeof context.trace === "function" ? context.trace : void 0;
|
|
165
|
+
const embedText = async (text) => {
|
|
166
|
+
model ??= resolveEmbeddingModel(config.embeddingModel, context.ai);
|
|
167
|
+
const resolvedModel = model;
|
|
168
|
+
const run = async (span) => {
|
|
169
|
+
const { embedding, providerMetadata, usage } = await embed({ model: resolvedModel, value: text });
|
|
170
|
+
if (span !== void 0) {
|
|
171
|
+
const inputTokens = usage.tokens;
|
|
172
|
+
if (typeof inputTokens === "number" && Number.isFinite(inputTokens)) {
|
|
173
|
+
span.setAttribute("gen_ai.usage.input_tokens", inputTokens);
|
|
174
|
+
}
|
|
175
|
+
const cost = embedCostOf(providerMetadata);
|
|
176
|
+
if (cost !== void 0) {
|
|
177
|
+
span.setAttribute("gen_ai.usage.cost", cost);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return embedding;
|
|
181
|
+
};
|
|
182
|
+
if (tracer === void 0) {
|
|
183
|
+
return run();
|
|
184
|
+
}
|
|
185
|
+
const modelId = modelIdOf(resolvedModel);
|
|
186
|
+
return tracer("ai.embed", (_trace, span) => run(span), {
|
|
187
|
+
"gen_ai.operation.name": "embeddings",
|
|
188
|
+
...modelId === void 0 ? {} : { "gen_ai.request.model": modelId }
|
|
189
|
+
});
|
|
190
|
+
};
|
|
191
|
+
const checkNamespace = (namespace) => {
|
|
192
|
+
if (namespace !== void 0) {
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
if (config.requireNamespace) {
|
|
196
|
+
throw new LunoraError(
|
|
197
|
+
"BAD_REQUEST",
|
|
198
|
+
`@lunora/ai/rag: index "${config.index}" requires a namespace (requireNamespace is set) — pass the tenant/shard key on index()/retrieve()/remove()`
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
if (!config.allowSharedNamespace) {
|
|
202
|
+
warnSharedNamespace(config.index);
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
const readHead = async (sourceId, namespace) => {
|
|
206
|
+
const [head] = await context.vectors.getByIds(config.index, [chunkVectorId(namespace, sourceId, 0)], namespace);
|
|
207
|
+
const hash = head?.metadata?.[HASH_KEY];
|
|
208
|
+
const chunks = head?.metadata?.[COUNT_KEY];
|
|
209
|
+
return {
|
|
210
|
+
chunks: typeof chunks === "number" && Number.isInteger(chunks) && chunks > 0 ? chunks : void 0,
|
|
211
|
+
hash: typeof hash === "string" ? hash : void 0
|
|
212
|
+
};
|
|
213
|
+
};
|
|
214
|
+
const deleteChunkRange = async (sourceId, from, to, namespace) => {
|
|
215
|
+
const ids = Array.from({ length: to - from }, (_, offset) => chunkVectorId(namespace, sourceId, from + offset));
|
|
216
|
+
if (ids.length === 0) {
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
await context.vectors.deleteByIds(config.index, ids, namespace);
|
|
220
|
+
await textStore?.remove?.(ids, { namespace });
|
|
221
|
+
await config.lexicalStore?.remove?.(ids, { namespace });
|
|
222
|
+
};
|
|
223
|
+
const index = async (input) => {
|
|
224
|
+
checkNamespace(input.namespace);
|
|
225
|
+
if (input.importance !== void 0 && (typeof input.importance !== "number" || input.importance < 0 || input.importance > 1)) {
|
|
226
|
+
throw new LunoraError("BAD_REQUEST", "@lunora/ai/rag: `importance` must be a number in [0, 1]");
|
|
227
|
+
}
|
|
228
|
+
const effectiveNamespace = withModelTag(input.namespace);
|
|
229
|
+
const hash = await sha256Hex(input.text);
|
|
230
|
+
const previous = await readHead(input.id, effectiveNamespace);
|
|
231
|
+
if (previous.hash === hash && previous.chunks !== void 0) {
|
|
232
|
+
return {
|
|
233
|
+
chunks: previous.chunks,
|
|
234
|
+
ids: Array.from({ length: previous.chunks }, (_, chunkIndex) => chunkVectorId(effectiveNamespace, input.id, chunkIndex)),
|
|
235
|
+
unchanged: true
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
const pieces = splitter(input.text);
|
|
239
|
+
const ids = pieces.map((_, chunkIndex) => chunkVectorId(effectiveNamespace, input.id, chunkIndex));
|
|
240
|
+
if (pieces.length === 0 && input.allowEmptySources === false) {
|
|
241
|
+
throw new LunoraError("BAD_REQUEST", `@lunora/ai/rag: source "${input.id}" produced zero chunks — set allowEmptySources: true to allow this`);
|
|
242
|
+
}
|
|
243
|
+
if (pieces.length > 0) {
|
|
244
|
+
const storedChunks = pieces.map((text, chunkIndex) => {
|
|
245
|
+
return { chunkIndex, id: ids[chunkIndex], sourceId: input.id, text };
|
|
246
|
+
});
|
|
247
|
+
if (textStore) {
|
|
248
|
+
await textStore.put(storedChunks, { namespace: effectiveNamespace });
|
|
249
|
+
}
|
|
250
|
+
if (config.lexicalStore) {
|
|
251
|
+
await config.lexicalStore.index(storedChunks, { namespace: effectiveNamespace });
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
await concurrentMap(pieces, INDEX_CONCURRENCY, async (piece, chunkIndex) => {
|
|
255
|
+
const id = ids[chunkIndex];
|
|
256
|
+
const metadata = {
|
|
257
|
+
...input.metadata,
|
|
258
|
+
[CHUNK_INDEX_KEY]: chunkIndex,
|
|
259
|
+
[SOURCE_KEY]: input.id
|
|
260
|
+
};
|
|
261
|
+
if (!textStore) {
|
|
262
|
+
metadata[TEXT_KEY] = piece;
|
|
263
|
+
}
|
|
264
|
+
if (input.importance !== void 0) {
|
|
265
|
+
metadata[IMPORTANCE_KEY] = input.importance;
|
|
266
|
+
}
|
|
267
|
+
if (chunkIndex === 0) {
|
|
268
|
+
metadata[HASH_KEY] = hash;
|
|
269
|
+
metadata[COUNT_KEY] = pieces.length;
|
|
270
|
+
if (modelTag !== void 0) {
|
|
271
|
+
metadata[MODEL_KEY] = modelTag;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
await context.vectors.upsert(config.index, {
|
|
275
|
+
embed: embedText,
|
|
276
|
+
id,
|
|
277
|
+
input: piece,
|
|
278
|
+
metadata,
|
|
279
|
+
namespace: effectiveNamespace
|
|
280
|
+
});
|
|
281
|
+
input.onChunk?.({ chunkIndex, id, text: piece, total: pieces.length });
|
|
282
|
+
});
|
|
283
|
+
if (previous.chunks !== void 0 && previous.chunks > pieces.length) {
|
|
284
|
+
await deleteChunkRange(input.id, pieces.length, previous.chunks, effectiveNamespace);
|
|
285
|
+
}
|
|
286
|
+
return { chunks: pieces.length, ids, unchanged: false };
|
|
287
|
+
};
|
|
288
|
+
const remove = async (input) => {
|
|
289
|
+
checkNamespace(input.namespace);
|
|
290
|
+
const effectiveNamespace = withModelTag(input.namespace);
|
|
291
|
+
const previous = await readHead(input.id, effectiveNamespace);
|
|
292
|
+
const totalChunks = previous.chunks ?? 1;
|
|
293
|
+
await deleteChunkRange(input.id, 0, totalChunks, effectiveNamespace);
|
|
294
|
+
};
|
|
295
|
+
const textsByIds = async (ids, namespace) => {
|
|
296
|
+
const texts = /* @__PURE__ */ new Map();
|
|
297
|
+
if (ids.length === 0) {
|
|
298
|
+
return texts;
|
|
299
|
+
}
|
|
300
|
+
if (textStore) {
|
|
301
|
+
const stored = await textStore.getMany(ids, { namespace });
|
|
302
|
+
for (const [position, id] of ids.entries()) {
|
|
303
|
+
const text = stored[position];
|
|
304
|
+
if (typeof text === "string") {
|
|
305
|
+
texts.set(id, text);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return texts;
|
|
309
|
+
}
|
|
310
|
+
const records = await context.vectors.getByIds(config.index, ids, namespace);
|
|
311
|
+
for (const record of records) {
|
|
312
|
+
const text = record.metadata?.[TEXT_KEY];
|
|
313
|
+
if (typeof text === "string") {
|
|
314
|
+
texts.set(record.id, text);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return texts;
|
|
318
|
+
};
|
|
319
|
+
const expandChunks = async (chunks, options, effectiveNamespace) => {
|
|
320
|
+
const before = options?.chunkContext?.before ?? 0;
|
|
321
|
+
const after = options?.chunkContext?.after ?? 0;
|
|
322
|
+
if (before === 0 && after === 0) {
|
|
323
|
+
return chunks;
|
|
324
|
+
}
|
|
325
|
+
if (!Number.isInteger(before) || before < 0 || !Number.isInteger(after) || after < 0) {
|
|
326
|
+
throw new LunoraError("BAD_REQUEST", "@lunora/ai/rag: `chunkContext.before`/`chunkContext.after` must be non-negative integers");
|
|
327
|
+
}
|
|
328
|
+
const known = new Map(chunks.map((chunk) => [chunk.id, chunk.text]));
|
|
329
|
+
const neighbourIds = /* @__PURE__ */ new Set();
|
|
330
|
+
for (const chunk of chunks) {
|
|
331
|
+
for (let offset = -before; offset <= after; offset += 1) {
|
|
332
|
+
const neighbourIndex = chunk.chunkIndex + offset;
|
|
333
|
+
const id = chunkVectorId(effectiveNamespace, chunk.sourceId, neighbourIndex);
|
|
334
|
+
if (offset !== 0 && neighbourIndex >= 0 && !known.has(id)) {
|
|
335
|
+
neighbourIds.add(id);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
const neighbourTexts = await textsByIds([...neighbourIds], effectiveNamespace);
|
|
340
|
+
const textOf = (sourceId, chunkIndex) => {
|
|
341
|
+
const id = chunkVectorId(effectiveNamespace, sourceId, chunkIndex);
|
|
342
|
+
return known.get(id) ?? neighbourTexts.get(id);
|
|
343
|
+
};
|
|
344
|
+
return chunks.map((chunk) => {
|
|
345
|
+
const parts = [];
|
|
346
|
+
for (let offset = -before; offset <= after; offset += 1) {
|
|
347
|
+
const text = offset === 0 ? chunk.text : textOf(chunk.sourceId, chunk.chunkIndex + offset);
|
|
348
|
+
if (text !== void 0) {
|
|
349
|
+
parts.push(text);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return { ...chunk, text: parts.join("\n") };
|
|
353
|
+
});
|
|
354
|
+
};
|
|
355
|
+
const resolveFilter = (filter) => {
|
|
356
|
+
if (typeof filter === "string") {
|
|
357
|
+
const resolved = config.filters?.[filter];
|
|
358
|
+
if (!resolved) {
|
|
359
|
+
throw new LunoraError(
|
|
360
|
+
"NOT_FOUND",
|
|
361
|
+
`@lunora/ai/rag: unknown named filter "${filter}" — must be one of the keys declared in RagConfig.filters`
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
return resolved.filter;
|
|
365
|
+
}
|
|
366
|
+
return filter;
|
|
367
|
+
};
|
|
368
|
+
const parseMatches = (result, namespace) => result.matches.map((match) => {
|
|
369
|
+
const metadata = match.metadata ?? {};
|
|
370
|
+
const parsed = parseChunkVectorId(match.id, namespace);
|
|
371
|
+
const rawText = metadata[TEXT_KEY];
|
|
372
|
+
const rawImportance = metadata[IMPORTANCE_KEY];
|
|
373
|
+
const importance = typeof rawImportance === "number" && rawImportance >= 0 && rawImportance <= 1 ? rawImportance : 1;
|
|
374
|
+
return {
|
|
375
|
+
chunkIndex: parsed.chunkIndex,
|
|
376
|
+
id: match.id,
|
|
377
|
+
importance,
|
|
378
|
+
metadata: userMetadataOf(metadata),
|
|
379
|
+
score: match.score * importance,
|
|
380
|
+
sourceId: parsed.sourceId,
|
|
381
|
+
text: typeof rawText === "string" ? rawText : ""
|
|
382
|
+
};
|
|
383
|
+
});
|
|
384
|
+
const hydrateFromStore = async (chunks, namespace) => {
|
|
385
|
+
if (!textStore) {
|
|
386
|
+
return chunks;
|
|
387
|
+
}
|
|
388
|
+
const ids = chunks.map((chunk) => chunk.id);
|
|
389
|
+
const [texts, records] = await Promise.all([textsByIds(ids, namespace), context.vectors.getByIds(config.index, ids, namespace)]);
|
|
390
|
+
const fullMetadataById = new Map(records.map((record) => [record.id, record.metadata]));
|
|
391
|
+
return chunks.flatMap((chunk) => {
|
|
392
|
+
const text = texts.get(chunk.id);
|
|
393
|
+
if (text === void 0) {
|
|
394
|
+
return [];
|
|
395
|
+
}
|
|
396
|
+
const fullMetadata = fullMetadataById.get(chunk.id);
|
|
397
|
+
const rawImportance = fullMetadata?.[IMPORTANCE_KEY];
|
|
398
|
+
const importance = typeof rawImportance === "number" && rawImportance >= 0 && rawImportance <= 1 ? rawImportance : chunk.importance;
|
|
399
|
+
const score = chunk.score / chunk.importance * importance;
|
|
400
|
+
return [{ ...chunk, importance, metadata: userMetadataOf(fullMetadata) ?? chunk.metadata, score, text }];
|
|
401
|
+
});
|
|
402
|
+
};
|
|
403
|
+
const retrieve = async (query, options) => {
|
|
404
|
+
checkNamespace(options?.namespace);
|
|
405
|
+
const effectiveNamespace = withModelTag(options?.namespace);
|
|
406
|
+
const resolvedFilter = resolveFilter(options?.filter);
|
|
407
|
+
const rlsFilter = config.rlsFilter ? await config.rlsFilter(context.auth) : void 0;
|
|
408
|
+
const effectiveFilter = rlsFilter ? { ...resolvedFilter, ...rlsFilter } : resolvedFilter;
|
|
409
|
+
const topK = Math.min(options?.topK ?? defaultTopK, topKCeiling);
|
|
410
|
+
const vectorResult = await context.vectors.query(config.index, {
|
|
411
|
+
embed: embedText,
|
|
412
|
+
filter: effectiveFilter,
|
|
413
|
+
input: query,
|
|
414
|
+
namespace: effectiveNamespace,
|
|
415
|
+
returnMetadata: textStore ? "indexed" : "all",
|
|
416
|
+
topK
|
|
417
|
+
});
|
|
418
|
+
let chunks = await hydrateFromStore(parseMatches(vectorResult, effectiveNamespace), effectiveNamespace);
|
|
419
|
+
const minScore = options?.minScore;
|
|
420
|
+
if (minScore !== void 0) {
|
|
421
|
+
chunks = chunks.filter((chunk) => chunk.score >= minScore);
|
|
422
|
+
}
|
|
423
|
+
if (config.lexicalStore) {
|
|
424
|
+
const lexicalMatches = await config.lexicalStore.search(query, {
|
|
425
|
+
filter: effectiveFilter,
|
|
426
|
+
namespace: effectiveNamespace,
|
|
427
|
+
topK: config.lexicalTopK ?? topK
|
|
428
|
+
});
|
|
429
|
+
const lexicalChunks = lexicalMatches.map((match) => {
|
|
430
|
+
const parsed = parseChunkVectorId(match.id, effectiveNamespace);
|
|
431
|
+
return {
|
|
432
|
+
chunkIndex: parsed.chunkIndex,
|
|
433
|
+
id: match.id,
|
|
434
|
+
importance: 1,
|
|
435
|
+
metadata: void 0,
|
|
436
|
+
score: match.score,
|
|
437
|
+
sourceId: parsed.sourceId,
|
|
438
|
+
text: match.text
|
|
439
|
+
};
|
|
440
|
+
});
|
|
441
|
+
chunks = [...hybridRank(chunks, lexicalChunks)];
|
|
442
|
+
}
|
|
443
|
+
chunks.sort((a, b) => b.score - a.score);
|
|
444
|
+
chunks = [...await expandChunks(chunks, options, effectiveNamespace)];
|
|
445
|
+
const sources = [];
|
|
446
|
+
const seen = /* @__PURE__ */ new Set();
|
|
447
|
+
for (const chunk of chunks) {
|
|
448
|
+
if (!seen.has(chunk.sourceId)) {
|
|
449
|
+
seen.add(chunk.sourceId);
|
|
450
|
+
sources.push({ id: chunk.sourceId, metadata: chunk.metadata, weight: chunk.importance });
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
options?.onRetrieve?.({ matches: chunks.length, query });
|
|
454
|
+
return { chunks, context: assembleContext(chunks), sources };
|
|
455
|
+
};
|
|
456
|
+
const asTool = (options) => tool({
|
|
457
|
+
description: options?.description ?? `Search the "${config.index}" knowledge base for passages relevant to a natural-language query.`,
|
|
458
|
+
execute: async ({ query }) => retrieve(query, { namespace: options?.namespace, topK: options?.topK }),
|
|
459
|
+
inputSchema: jsonSchema({
|
|
460
|
+
properties: {
|
|
461
|
+
query: { description: "The natural-language search query.", type: "string" }
|
|
462
|
+
},
|
|
463
|
+
required: ["query"],
|
|
464
|
+
type: "object"
|
|
465
|
+
})
|
|
466
|
+
});
|
|
467
|
+
return { asTool, index, remove, retrieve };
|
|
468
|
+
};
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
export { defineRag as default };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
const fixedWindowChunks = (text, size, overlap) => {
|
|
2
|
+
if (!Number.isInteger(size) || size < 1) {
|
|
3
|
+
throw new RangeError("fixedWindowChunks: `size` must be a positive integer");
|
|
4
|
+
}
|
|
5
|
+
if (!Number.isInteger(overlap) || overlap < 0 || overlap >= size) {
|
|
6
|
+
throw new RangeError("fixedWindowChunks: `overlap` must be a non-negative integer smaller than `size`");
|
|
7
|
+
}
|
|
8
|
+
const trimmed = text.trim();
|
|
9
|
+
if (trimmed.length === 0) {
|
|
10
|
+
return [];
|
|
11
|
+
}
|
|
12
|
+
if (trimmed.length <= size) {
|
|
13
|
+
return [trimmed];
|
|
14
|
+
}
|
|
15
|
+
const step = Math.max(1, size - overlap);
|
|
16
|
+
const chunks = [];
|
|
17
|
+
for (let start = 0; start < trimmed.length; start += step) {
|
|
18
|
+
chunks.push(trimmed.slice(start, start + size));
|
|
19
|
+
if (start + size >= trimmed.length) {
|
|
20
|
+
break;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return chunks;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export { fixedWindowChunks as default };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
const hybridRank = (vectorResults, textResults, k = 60) => {
|
|
2
|
+
const fused = /* @__PURE__ */ new Map();
|
|
3
|
+
for (const [rank, chunk] of vectorResults.entries()) {
|
|
4
|
+
fused.set(chunk.id, { chunk, score: 1 / (k + rank), vectorRank: rank });
|
|
5
|
+
}
|
|
6
|
+
for (const [rank, chunk] of textResults.entries()) {
|
|
7
|
+
const existing = fused.get(chunk.id);
|
|
8
|
+
if (existing) {
|
|
9
|
+
existing.score += 1 / (k + rank);
|
|
10
|
+
} else {
|
|
11
|
+
fused.set(chunk.id, { chunk, score: 1 / (k + rank), vectorRank: Number.POSITIVE_INFINITY });
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
return [...fused.values()].toSorted((a, b) => {
|
|
15
|
+
const delta = b.score - a.score;
|
|
16
|
+
return delta === 0 ? a.vectorRank - b.vectorRank : delta;
|
|
17
|
+
}).map((entry) => entry.chunk);
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export { hybridRank as default };
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { EmbeddingModel, LanguageModel } from 'ai';
|
|
2
|
+
/**
|
|
3
|
+
* Structural projection of the Cloudflare Workers `AI` binding (`env.AI`).
|
|
4
|
+
* Declared locally so unit tests can pass a plain-object double and the real
|
|
5
|
+
* binding satisfies the same shape without importing `@cloudflare/workers-types`
|
|
6
|
+
* into the public surface. Mirrors the `run` method documented at
|
|
7
|
+
* https://developers.cloudflare.com/workers-ai/.
|
|
8
|
+
* @experimental
|
|
9
|
+
*/
|
|
10
|
+
interface AiBindingLike {
|
|
11
|
+
run: (model: string, inputs: Record<string, unknown>, options?: Record<string, unknown>) => Promise<unknown>;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* A Workers AI provider instance — the value returned by `createWorkersAI(...)`.
|
|
15
|
+
* Calling it with a model id yields an AI SDK {@link LanguageModel}; the
|
|
16
|
+
* optional `textEmbeddingModel` factory yields an {@link EmbeddingModel}.
|
|
17
|
+
* Typed structurally so `@lunora/ai` neither re-declares the provider's full
|
|
18
|
+
* surface nor hard-pins its exact type across minor releases.
|
|
19
|
+
* @experimental
|
|
20
|
+
*/
|
|
21
|
+
interface WorkersAiProviderLike {
|
|
22
|
+
(modelId: string, settings?: Record<string, unknown>): LanguageModel;
|
|
23
|
+
textEmbeddingModel?: (modelId: string) => EmbeddingModel;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* AI Gateway options forwarded to `createWorkersAI`. Lets inference route
|
|
27
|
+
* through a Cloudflare AI Gateway for caching, rate-limiting, and observability.
|
|
28
|
+
* @experimental
|
|
29
|
+
*/
|
|
30
|
+
interface AiGatewayOptions {
|
|
31
|
+
[key: string]: unknown;
|
|
32
|
+
id: string;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* `LunoraAiOptions` is part of the experimental `@lunora/ai` API and may change without a major version bump.
|
|
36
|
+
* @experimental
|
|
37
|
+
*/
|
|
38
|
+
interface LunoraAiOptions {
|
|
39
|
+
/**
|
|
40
|
+
* The Workers `AI` binding (`env.AI`). Required for the zero-config Workers
|
|
41
|
+
* AI default and for the raw `ai.run(...)` passthrough. May be omitted when
|
|
42
|
+
* a pre-built `provider` is supplied (e.g. in tests or a custom setup).
|
|
43
|
+
*/
|
|
44
|
+
binding?: AiBindingLike;
|
|
45
|
+
/**
|
|
46
|
+
* Default Workers AI **embedding** model id used by `embeddingModel()` when no
|
|
47
|
+
* explicit model is passed (e.g. `@cf/baai/bge-base-en-v1.5`). Kept separate
|
|
48
|
+
* from `defaultModel` because a language-model id and an embedding-model
|
|
49
|
+
* id belong to different Workers AI families and are never interchangeable —
|
|
50
|
+
* reusing the language-model default here would defer a wrong-family error to
|
|
51
|
+
* inference time. Has no effect on bring-your-own providers.
|
|
52
|
+
*/
|
|
53
|
+
defaultEmbeddingModel?: string;
|
|
54
|
+
/**
|
|
55
|
+
* Default Workers AI **language** model id used by `model()` when no explicit
|
|
56
|
+
* model is passed. For embeddings, set `defaultEmbeddingModel` instead.
|
|
57
|
+
* Has no effect on bring-your-own providers.
|
|
58
|
+
*/
|
|
59
|
+
defaultModel?: string;
|
|
60
|
+
/** Route Workers AI inference through a Cloudflare AI Gateway. */
|
|
61
|
+
gateway?: AiGatewayOptions;
|
|
62
|
+
/**
|
|
63
|
+
* Pre-built Workers AI provider. When omitted, one is constructed from
|
|
64
|
+
* `binding` via `createWorkersAI`. Supplying it directly is the seam used by
|
|
65
|
+
* tests and advanced setups; it also lets callers configure the provider
|
|
66
|
+
* (e.g. `safePrompt`) before handing it to `@lunora/ai`.
|
|
67
|
+
*/
|
|
68
|
+
provider?: WorkersAiProviderLike;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* A model to run against. The AI SDK's {@link LanguageModel} already admits a
|
|
72
|
+
* bare `string`, so this alias covers both arms of the provider-agnostic seam:
|
|
73
|
+
* a string id is the Workers AI convenience path (resolved by `ctx.ai.model`),
|
|
74
|
+
* a built model object is bring-your-own (`@ai-sdk/openai`, `@ai-sdk/anthropic`,
|
|
75
|
+
* `@ai-sdk/google`, OpenRouter, …).
|
|
76
|
+
* @experimental
|
|
77
|
+
*/
|
|
78
|
+
type ModelInput = LanguageModel;
|
|
79
|
+
/**
|
|
80
|
+
* Likewise for embeddings: a Workers AI embedding model id (e.g.
|
|
81
|
+
* `@cf/baai/bge-base-en-v1.5`) or any AI SDK {@link EmbeddingModel}.
|
|
82
|
+
* @experimental
|
|
83
|
+
*/
|
|
84
|
+
type EmbeddingModelInput = EmbeddingModel | string;
|
|
85
|
+
/**
|
|
86
|
+
* The `ctx.ai` surface. `model`/`embeddingModel` resolve a Workers AI model from
|
|
87
|
+
* a string (the default provider) and pass any non-string model straight through,
|
|
88
|
+
* so both accept Workers AI and bring-your-own providers. Feed the resolved model
|
|
89
|
+
* to the AI SDK functions re-exported from `@lunora/ai` (`generateText`,
|
|
90
|
+
* `streamText`, `generateObject`, `embed`, …); `run` is the raw binding escape
|
|
91
|
+
* hatch, and `workersai` is the underlying provider for direct model access.
|
|
92
|
+
* @experimental
|
|
93
|
+
*/
|
|
94
|
+
interface LunoraAi {
|
|
95
|
+
/** Resolve an {@link EmbeddingModel}: a string → Workers AI, an object → passthrough. */
|
|
96
|
+
embeddingModel: (model?: EmbeddingModelInput) => EmbeddingModel;
|
|
97
|
+
/** Resolve a {@link LanguageModel}: a string → Workers AI, an object → passthrough. */
|
|
98
|
+
model: (model?: ModelInput) => LanguageModel;
|
|
99
|
+
/**
|
|
100
|
+
* Raw Workers AI binding passthrough (void-style `ai.run`). Bypasses the AI
|
|
101
|
+
* SDK entirely — useful for Workers-AI-only model families (image, ASR,
|
|
102
|
+
* translation) not surfaced through the provider. Throws if no binding was
|
|
103
|
+
* supplied.
|
|
104
|
+
*/
|
|
105
|
+
run: (model: string, inputs: Record<string, unknown>, options?: Record<string, unknown>) => Promise<unknown>;
|
|
106
|
+
/** The underlying Workers AI provider — `ai.workersai("@cf/...")` for a raw model. */
|
|
107
|
+
workersai: WorkersAiProviderLike;
|
|
108
|
+
}
|
|
109
|
+
export { AiBindingLike as A, EmbeddingModelInput as E, LunoraAiOptions as L, ModelInput as M, WorkersAiProviderLike as W, LunoraAi as a, AiGatewayOptions as b };
|