libsql-search 0.1.6 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +407 -66
- package/dist/index.d.ts +46 -4
- package/dist/index.esm.js +404 -67
- package/docs/API.md +103 -0
- package/docs/PROVIDERS.md +66 -0
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -4,7 +4,15 @@ var promises = require('fs/promises');
|
|
|
4
4
|
var path = require('path');
|
|
5
5
|
var matter = require('gray-matter');
|
|
6
6
|
|
|
7
|
-
const
|
|
7
|
+
const DEFAULT_DIMENSIONS = 768;
|
|
8
|
+
const DEFAULT_MAX_LENGTH = 8e3;
|
|
9
|
+
const DEFAULT_TIMEOUT_MS = 3e4;
|
|
10
|
+
const LOCAL_MODEL = "Xenova/all-MiniLM-L6-v2";
|
|
11
|
+
const GEMINI_MODEL = "text-embedding-004";
|
|
12
|
+
const OPENAI_SMALL_MODEL = "text-embedding-3-small";
|
|
13
|
+
const OPENAI_LARGE_MODEL = "text-embedding-3-large";
|
|
14
|
+
const OPENAI_EMBEDDINGS_URL = "https://api.openai.com/v1/embeddings";
|
|
15
|
+
const localModelCacheByModel = /* @__PURE__ */ new Map();
|
|
8
16
|
function getEnvironmentVariable(name) {
|
|
9
17
|
const runtime = globalThis;
|
|
10
18
|
const nodeValue = runtime.process?.env?.[name];
|
|
@@ -17,80 +25,403 @@ function getEnvironmentVariable(name) {
|
|
|
17
25
|
return void 0;
|
|
18
26
|
}
|
|
19
27
|
}
|
|
20
|
-
async function getLocalEmbeddingModel() {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
providerCache.local = await pipeline("feature-extraction", "Xenova/all-MiniLM-L6-v2");
|
|
25
|
-
console.log("Local model loaded successfully");
|
|
28
|
+
async function getLocalEmbeddingModel(modelName) {
|
|
29
|
+
const cached = localModelCacheByModel.get(modelName);
|
|
30
|
+
if (cached?.model) {
|
|
31
|
+
return cached.model;
|
|
26
32
|
}
|
|
27
|
-
|
|
33
|
+
console.log(`Loading local embedding model (${modelName})...`);
|
|
34
|
+
const { pipeline } = await import('@xenova/transformers');
|
|
35
|
+
const model = await pipeline("feature-extraction", modelName);
|
|
36
|
+
localModelCacheByModel.set(modelName, { model });
|
|
37
|
+
console.log("Local model loaded successfully");
|
|
38
|
+
return model;
|
|
28
39
|
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
40
|
+
function getPositiveInteger(value, optionName) {
|
|
41
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
42
|
+
throw new Error(`Invalid ${optionName}: expected a positive integer`);
|
|
43
|
+
}
|
|
44
|
+
return value;
|
|
45
|
+
}
|
|
46
|
+
function getTimeoutMs(value) {
|
|
47
|
+
return getPositiveInteger(value ?? DEFAULT_TIMEOUT_MS, "timeoutMs");
|
|
48
|
+
}
|
|
49
|
+
function resolveProviderName(provider) {
|
|
50
|
+
switch (provider ?? "local") {
|
|
38
51
|
case "local":
|
|
39
|
-
return generateLocalEmbedding(truncated, dimensions);
|
|
40
52
|
case "gemini":
|
|
41
|
-
return generateGeminiEmbedding(truncated, apiKey);
|
|
42
53
|
case "openai":
|
|
43
|
-
return
|
|
54
|
+
return provider ?? "local";
|
|
55
|
+
default:
|
|
56
|
+
throw new Error(`Unknown embedding provider: ${String(provider)}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function resolveIntent(intent) {
|
|
60
|
+
switch (intent ?? "document") {
|
|
61
|
+
case "document":
|
|
62
|
+
case "query":
|
|
63
|
+
return intent ?? "document";
|
|
44
64
|
default:
|
|
45
|
-
throw new Error(`Unknown embedding
|
|
65
|
+
throw new Error(`Unknown embedding intent: ${String(intent)}`);
|
|
46
66
|
}
|
|
47
67
|
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
68
|
+
function truncateTexts(texts, maxLength) {
|
|
69
|
+
return texts.map((text) => text.substring(0, maxLength));
|
|
70
|
+
}
|
|
71
|
+
function getOpenAIModel(dimensions) {
|
|
72
|
+
return dimensions <= 1536 ? OPENAI_SMALL_MODEL : OPENAI_LARGE_MODEL;
|
|
73
|
+
}
|
|
74
|
+
function hasOwnProperty(value, property) {
|
|
75
|
+
return Object.prototype.hasOwnProperty.call(value, property);
|
|
76
|
+
}
|
|
77
|
+
function redactErrorText(value) {
|
|
78
|
+
const raw = value instanceof Error ? value.message : String(value);
|
|
79
|
+
return raw.replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer [redacted]").replace(/([?&](?:api[_-]?key|key|token)=)[^&\s]+/gi, "$1[redacted]").replace(/(authorization\s*[:=]\s*)[^\s,}]+/gi, "$1[redacted]").replace(/https?:\/\/[^\s)]+/gi, (match) => {
|
|
80
|
+
try {
|
|
81
|
+
const url = new URL(match);
|
|
82
|
+
return `${url.origin}${url.pathname}`;
|
|
83
|
+
} catch {
|
|
84
|
+
return "[redacted-url]";
|
|
85
|
+
}
|
|
86
|
+
}).slice(0, 300);
|
|
87
|
+
}
|
|
88
|
+
function providerError(provider, message, cause) {
|
|
89
|
+
const safeCause = cause === void 0 ? "" : `: ${redactErrorText(cause).trim()}`;
|
|
90
|
+
return new Error(`${provider} embedding error: ${message}${safeCause}`);
|
|
91
|
+
}
|
|
92
|
+
function getResponseHeader(response, name) {
|
|
93
|
+
return response.headers.get(name) ?? void 0;
|
|
94
|
+
}
|
|
95
|
+
async function withTimeout(provider, operation, timeoutMs, run, parentSignal) {
|
|
96
|
+
if (parentSignal?.aborted) {
|
|
97
|
+
throw providerError(provider, `${operation} was aborted`);
|
|
98
|
+
}
|
|
99
|
+
const controller = new AbortController();
|
|
100
|
+
let abortError;
|
|
101
|
+
let rejectAbort = () => {
|
|
102
|
+
};
|
|
103
|
+
const abortPromise = new Promise((_resolve, reject) => {
|
|
104
|
+
rejectAbort = reject;
|
|
53
105
|
});
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
}
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
106
|
+
const abort = (error) => {
|
|
107
|
+
if (abortError) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
abortError = error;
|
|
111
|
+
controller.abort();
|
|
112
|
+
rejectAbort(error);
|
|
113
|
+
};
|
|
114
|
+
const onParentAbort = () => abort(providerError(provider, `${operation} was aborted`));
|
|
115
|
+
const timeout = setTimeout(() => {
|
|
116
|
+
abort(providerError(provider, `${operation} timed out after ${timeoutMs}ms`));
|
|
117
|
+
}, timeoutMs);
|
|
118
|
+
parentSignal?.addEventListener("abort", onParentAbort, { once: true });
|
|
119
|
+
const operationPromise = Promise.resolve().then(() => run(controller.signal));
|
|
120
|
+
operationPromise.catch(() => void 0);
|
|
121
|
+
try {
|
|
122
|
+
return await Promise.race([operationPromise, abortPromise]);
|
|
123
|
+
} catch (error) {
|
|
124
|
+
if (error === abortError) {
|
|
125
|
+
throw error;
|
|
126
|
+
}
|
|
127
|
+
throw providerError(provider, `${operation} failed`, error);
|
|
128
|
+
} finally {
|
|
129
|
+
clearTimeout(timeout);
|
|
130
|
+
parentSignal?.removeEventListener("abort", onParentAbort);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
function validateEmbeddingBatch(items, expectedCount, expectedDimensions, provider) {
|
|
134
|
+
if (items.length !== expectedCount) {
|
|
135
|
+
throw providerError(
|
|
136
|
+
provider,
|
|
137
|
+
`expected ${expectedCount} embedding result(s), received ${items.length}`
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
const hasIndexedItems = items.some((item) => !Array.isArray(item) && hasOwnProperty(item, "index"));
|
|
141
|
+
const ordered = hasIndexedItems ? reorderIndexedEmbeddings(items, expectedCount, provider) : items.map((item) => Array.isArray(item) ? item : item.embedding);
|
|
142
|
+
return ordered.map(
|
|
143
|
+
(embedding, itemIndex) => validateEmbeddingVector(embedding, expectedDimensions, provider, itemIndex)
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
function reorderIndexedEmbeddings(items, expectedCount, provider) {
|
|
147
|
+
const ordered = new Array(expectedCount);
|
|
148
|
+
const seen = /* @__PURE__ */ new Set();
|
|
149
|
+
for (const item of items) {
|
|
150
|
+
if (Array.isArray(item) || !hasOwnProperty(item, "index")) {
|
|
151
|
+
throw providerError(provider, "provider returned a partially indexed embedding batch");
|
|
152
|
+
}
|
|
153
|
+
if (typeof item.index !== "number" || !Number.isInteger(item.index) || item.index < 0 || item.index >= expectedCount) {
|
|
154
|
+
throw providerError(provider, `provider returned invalid embedding index ${String(item.index)}`);
|
|
155
|
+
}
|
|
156
|
+
if (seen.has(item.index)) {
|
|
157
|
+
throw providerError(provider, `provider returned duplicate embedding index ${item.index}`);
|
|
158
|
+
}
|
|
159
|
+
seen.add(item.index);
|
|
160
|
+
ordered[item.index] = item.embedding;
|
|
161
|
+
}
|
|
162
|
+
if (seen.size !== expectedCount) {
|
|
163
|
+
throw providerError(provider, "provider returned non-contiguous embedding indices");
|
|
164
|
+
}
|
|
165
|
+
return ordered;
|
|
166
|
+
}
|
|
167
|
+
function validateEmbeddingVector(embedding, expectedDimensions, provider, itemIndex) {
|
|
168
|
+
if (!Array.isArray(embedding)) {
|
|
169
|
+
throw providerError(provider, `embedding ${itemIndex} is not an array`);
|
|
170
|
+
}
|
|
171
|
+
if (embedding.length !== expectedDimensions) {
|
|
172
|
+
throw providerError(
|
|
173
|
+
provider,
|
|
174
|
+
`embedding ${itemIndex} has ${embedding.length} dimensions, expected ${expectedDimensions}`
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
for (let i = 0; i < embedding.length; i++) {
|
|
178
|
+
const value = embedding[i];
|
|
179
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
180
|
+
throw providerError(provider, `embedding ${itemIndex} contains a non-finite value at dimension ${i}`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return embedding;
|
|
184
|
+
}
|
|
185
|
+
function createProviderMetadata(provider, dimensions) {
|
|
186
|
+
switch (provider) {
|
|
187
|
+
case "local":
|
|
188
|
+
return Object.freeze({
|
|
189
|
+
name: "local",
|
|
190
|
+
model: LOCAL_MODEL,
|
|
191
|
+
dimensions,
|
|
192
|
+
batch: Object.freeze({ mode: "sequential" })
|
|
193
|
+
});
|
|
194
|
+
case "gemini":
|
|
195
|
+
return Object.freeze({
|
|
196
|
+
name: "gemini",
|
|
197
|
+
model: GEMINI_MODEL,
|
|
198
|
+
dimensions: DEFAULT_DIMENSIONS,
|
|
199
|
+
batch: Object.freeze({ mode: "sequential" })
|
|
200
|
+
});
|
|
201
|
+
case "openai":
|
|
202
|
+
return Object.freeze({
|
|
203
|
+
name: "openai",
|
|
204
|
+
model: getOpenAIModel(dimensions),
|
|
205
|
+
dimensions,
|
|
206
|
+
batch: Object.freeze({ mode: "native", maxSize: 2048 })
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
function getEffectiveDimensions(provider, dimensions) {
|
|
211
|
+
if (provider === "gemini") {
|
|
212
|
+
return DEFAULT_DIMENSIONS;
|
|
213
|
+
}
|
|
214
|
+
return getPositiveInteger(dimensions ?? DEFAULT_DIMENSIONS, "dimensions");
|
|
215
|
+
}
|
|
216
|
+
function assertBatchSize(metadata, count) {
|
|
217
|
+
if (metadata.batch.maxSize !== void 0 && count > metadata.batch.maxSize) {
|
|
218
|
+
throw providerError(
|
|
219
|
+
metadata.name,
|
|
220
|
+
`batch size ${count} exceeds maximum ${metadata.batch.maxSize}`
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
function createEmbeddingBatchResult(metadata, intent, embeddings) {
|
|
225
|
+
return Object.freeze({
|
|
226
|
+
embeddings,
|
|
227
|
+
provider: metadata.name,
|
|
228
|
+
model: metadata.model,
|
|
229
|
+
dimensions: metadata.dimensions,
|
|
230
|
+
intent
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
class LocalEmbeddingProvider {
|
|
234
|
+
metadata;
|
|
235
|
+
#timeoutMs;
|
|
236
|
+
constructor(metadata, timeoutMs) {
|
|
237
|
+
this.metadata = metadata;
|
|
238
|
+
this.#timeoutMs = timeoutMs;
|
|
239
|
+
}
|
|
240
|
+
async embed(texts, options = {}) {
|
|
241
|
+
const intent = resolveIntent(options.intent);
|
|
242
|
+
if (texts.length === 0) {
|
|
243
|
+
return createEmbeddingBatchResult(this.metadata, intent, []);
|
|
244
|
+
}
|
|
245
|
+
assertBatchSize(this.metadata, texts.length);
|
|
246
|
+
const vectors = await withTimeout(
|
|
247
|
+
"local",
|
|
248
|
+
"model inference",
|
|
249
|
+
this.#timeoutMs,
|
|
250
|
+
async () => {
|
|
251
|
+
const model = await getLocalEmbeddingModel(this.metadata.model);
|
|
252
|
+
const results = [];
|
|
253
|
+
for (const text of texts) {
|
|
254
|
+
const output = await model(text, {
|
|
255
|
+
pooling: "mean",
|
|
256
|
+
normalize: true
|
|
257
|
+
});
|
|
258
|
+
const embedding = Array.from(output.data);
|
|
259
|
+
results.push(padEmbedding(embedding, this.metadata.dimensions));
|
|
260
|
+
}
|
|
261
|
+
return results;
|
|
262
|
+
},
|
|
263
|
+
options.signal
|
|
264
|
+
);
|
|
265
|
+
return createEmbeddingBatchResult(
|
|
266
|
+
this.metadata,
|
|
267
|
+
intent,
|
|
268
|
+
validateEmbeddingBatch(vectors, texts.length, this.metadata.dimensions, "local")
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
class GeminiEmbeddingProvider {
|
|
273
|
+
metadata;
|
|
274
|
+
#apiKey;
|
|
275
|
+
#timeoutMs;
|
|
276
|
+
constructor(metadata, apiKey, timeoutMs) {
|
|
277
|
+
this.metadata = metadata;
|
|
278
|
+
this.#apiKey = apiKey;
|
|
279
|
+
this.#timeoutMs = timeoutMs;
|
|
280
|
+
}
|
|
281
|
+
async embed(texts, options = {}) {
|
|
282
|
+
const intent = resolveIntent(options.intent);
|
|
283
|
+
if (texts.length === 0) {
|
|
284
|
+
return createEmbeddingBatchResult(this.metadata, intent, []);
|
|
285
|
+
}
|
|
286
|
+
assertBatchSize(this.metadata, texts.length);
|
|
287
|
+
const vectors = await withTimeout(
|
|
288
|
+
"gemini",
|
|
289
|
+
"API request",
|
|
290
|
+
this.#timeoutMs,
|
|
291
|
+
async () => {
|
|
292
|
+
const { GoogleGenerativeAI } = await import('@google/generative-ai');
|
|
293
|
+
const genAI = new GoogleGenerativeAI(this.#apiKey);
|
|
294
|
+
const model = genAI.getGenerativeModel({ model: this.metadata.model });
|
|
295
|
+
const results = [];
|
|
296
|
+
for (const text of texts) {
|
|
297
|
+
const result = await model.embedContent(text);
|
|
298
|
+
const values = result.embedding?.values;
|
|
299
|
+
if (!Array.isArray(values)) {
|
|
300
|
+
throw new Error("Gemini response did not include embedding values");
|
|
301
|
+
}
|
|
302
|
+
results.push(values);
|
|
303
|
+
}
|
|
304
|
+
return results;
|
|
305
|
+
},
|
|
306
|
+
options.signal
|
|
307
|
+
);
|
|
308
|
+
return createEmbeddingBatchResult(
|
|
309
|
+
this.metadata,
|
|
310
|
+
intent,
|
|
311
|
+
validateEmbeddingBatch(vectors, texts.length, this.metadata.dimensions, "gemini")
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
class OpenAIEmbeddingProvider {
|
|
316
|
+
metadata;
|
|
317
|
+
#apiKey;
|
|
318
|
+
#timeoutMs;
|
|
319
|
+
constructor(metadata, apiKey, timeoutMs) {
|
|
320
|
+
this.metadata = metadata;
|
|
321
|
+
this.#apiKey = apiKey;
|
|
322
|
+
this.#timeoutMs = timeoutMs;
|
|
323
|
+
}
|
|
324
|
+
async embed(texts, options = {}) {
|
|
325
|
+
const intent = resolveIntent(options.intent);
|
|
326
|
+
if (texts.length === 0) {
|
|
327
|
+
return createEmbeddingBatchResult(this.metadata, intent, []);
|
|
328
|
+
}
|
|
329
|
+
assertBatchSize(this.metadata, texts.length);
|
|
330
|
+
const items = await withTimeout(
|
|
331
|
+
"openai",
|
|
332
|
+
"API request",
|
|
333
|
+
this.#timeoutMs,
|
|
334
|
+
async (signal) => {
|
|
335
|
+
const response = await fetch(OPENAI_EMBEDDINGS_URL, {
|
|
336
|
+
method: "POST",
|
|
337
|
+
headers: {
|
|
338
|
+
"Content-Type": "application/json",
|
|
339
|
+
"Authorization": `Bearer ${this.#apiKey}`
|
|
340
|
+
},
|
|
341
|
+
signal,
|
|
342
|
+
body: JSON.stringify({
|
|
343
|
+
input: texts,
|
|
344
|
+
model: this.metadata.model,
|
|
345
|
+
dimensions: this.metadata.dimensions
|
|
346
|
+
})
|
|
347
|
+
});
|
|
348
|
+
if (!response.ok) {
|
|
349
|
+
const requestId = getResponseHeader(response, "x-request-id");
|
|
350
|
+
const context = requestId ? ` status ${response.status}, request ${requestId}` : ` status ${response.status}`;
|
|
351
|
+
throw new Error(context);
|
|
352
|
+
}
|
|
353
|
+
const data = await response.json();
|
|
354
|
+
if (!Array.isArray(data.data)) {
|
|
355
|
+
throw new Error("OpenAI response did not include a data array");
|
|
356
|
+
}
|
|
357
|
+
return data.data.map((item) => {
|
|
358
|
+
const typed = item;
|
|
359
|
+
const embedding = typed.embedding;
|
|
360
|
+
if (!Array.isArray(embedding)) {
|
|
361
|
+
throw new Error("OpenAI response item did not include an embedding array");
|
|
362
|
+
}
|
|
363
|
+
const parsed = { embedding };
|
|
364
|
+
if (hasOwnProperty(typed, "index")) {
|
|
365
|
+
parsed.index = typed.index;
|
|
366
|
+
}
|
|
367
|
+
return parsed;
|
|
368
|
+
});
|
|
369
|
+
},
|
|
370
|
+
options.signal
|
|
371
|
+
);
|
|
372
|
+
return createEmbeddingBatchResult(
|
|
373
|
+
this.metadata,
|
|
374
|
+
intent,
|
|
375
|
+
validateEmbeddingBatch(items, texts.length, this.metadata.dimensions, "openai")
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
function createEmbeddingProvider(options = {}) {
|
|
380
|
+
const provider = resolveProviderName(options.provider);
|
|
381
|
+
const metadata = getEmbeddingProviderMetadata(options);
|
|
382
|
+
const timeoutMs = getTimeoutMs(options.timeoutMs);
|
|
383
|
+
switch (provider) {
|
|
384
|
+
case "local":
|
|
385
|
+
return new LocalEmbeddingProvider(metadata, timeoutMs);
|
|
386
|
+
case "gemini": {
|
|
387
|
+
const key = options.apiKey || getEnvironmentVariable("GEMINI_API_KEY");
|
|
388
|
+
if (!key) {
|
|
389
|
+
throw new Error("GEMINI_API_KEY is required for Gemini embeddings");
|
|
390
|
+
}
|
|
391
|
+
return new GeminiEmbeddingProvider(metadata, key, timeoutMs);
|
|
392
|
+
}
|
|
393
|
+
case "openai": {
|
|
394
|
+
const key = options.apiKey || getEnvironmentVariable("OPENAI_API_KEY");
|
|
395
|
+
if (!key) {
|
|
396
|
+
throw new Error("OPENAI_API_KEY is required for OpenAI embeddings");
|
|
397
|
+
}
|
|
398
|
+
return new OpenAIEmbeddingProvider(metadata, key, timeoutMs);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
function getEmbeddingProviderMetadata(options = {}) {
|
|
403
|
+
const provider = resolveProviderName(options.provider);
|
|
404
|
+
return createProviderMetadata(provider, getEffectiveDimensions(provider, options.dimensions));
|
|
405
|
+
}
|
|
406
|
+
async function generateEmbeddings(texts, options = {}) {
|
|
407
|
+
const maxLength = getPositiveInteger(options.maxLength ?? DEFAULT_MAX_LENGTH, "maxLength");
|
|
408
|
+
const intent = resolveIntent(options.intent);
|
|
409
|
+
if (texts.length === 0) {
|
|
410
|
+
return [];
|
|
411
|
+
}
|
|
412
|
+
const provider = createEmbeddingProvider(options);
|
|
413
|
+
const result = await provider.embed(truncateTexts(texts, maxLength), {
|
|
414
|
+
intent,
|
|
415
|
+
...options.signal ? { signal: options.signal } : {}
|
|
87
416
|
});
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
417
|
+
return result.embeddings;
|
|
418
|
+
}
|
|
419
|
+
async function generateEmbedding(text, options = {}) {
|
|
420
|
+
const [embedding] = await generateEmbeddings([text], options);
|
|
421
|
+
if (!embedding) {
|
|
422
|
+
throw new Error("Embedding provider returned no embedding");
|
|
91
423
|
}
|
|
92
|
-
|
|
93
|
-
return data.data[0].embedding;
|
|
424
|
+
return embedding;
|
|
94
425
|
}
|
|
95
426
|
function padEmbedding(embedding, targetDimensions) {
|
|
96
427
|
if (embedding.length === targetDimensions) {
|
|
@@ -211,7 +542,10 @@ async function processFile(file, embeddingOptions) {
|
|
|
211
542
|
content: markdown,
|
|
212
543
|
tags
|
|
213
544
|
});
|
|
214
|
-
const embedding = await generateEmbedding(embeddingText,
|
|
545
|
+
const embedding = await generateEmbedding(embeddingText, {
|
|
546
|
+
...embeddingOptions,
|
|
547
|
+
intent: embeddingOptions.intent ?? "document"
|
|
548
|
+
});
|
|
215
549
|
return {
|
|
216
550
|
slug,
|
|
217
551
|
title,
|
|
@@ -280,7 +614,10 @@ async function search(options) {
|
|
|
280
614
|
} = options;
|
|
281
615
|
const quotedTableName = quoteSqlIdentifier(tableName, "tableName");
|
|
282
616
|
const resultLimit = normalizeSearchLimit(limit);
|
|
283
|
-
const queryEmbedding = await generateEmbedding(query,
|
|
617
|
+
const queryEmbedding = await generateEmbedding(query, {
|
|
618
|
+
...embeddingOptions,
|
|
619
|
+
intent: embeddingOptions.intent ?? "query"
|
|
620
|
+
});
|
|
284
621
|
const results = await client.execute({
|
|
285
622
|
sql: `
|
|
286
623
|
SELECT
|
|
@@ -382,13 +719,17 @@ async function getFolders(client, tableName = "articles") {
|
|
|
382
719
|
return results.rows.map((row) => row.folder);
|
|
383
720
|
}
|
|
384
721
|
|
|
722
|
+
exports.createEmbeddingProvider = createEmbeddingProvider;
|
|
385
723
|
exports.createTable = createTable;
|
|
386
724
|
exports.generateEmbedding = generateEmbedding;
|
|
725
|
+
exports.generateEmbeddings = generateEmbeddings;
|
|
387
726
|
exports.getAllArticles = getAllArticles;
|
|
388
727
|
exports.getArticleBySlug = getArticleBySlug;
|
|
389
728
|
exports.getArticlesByFolder = getArticlesByFolder;
|
|
729
|
+
exports.getEmbeddingProviderMetadata = getEmbeddingProviderMetadata;
|
|
390
730
|
exports.getFolders = getFolders;
|
|
391
731
|
exports.indexContent = indexContent;
|
|
392
732
|
exports.padEmbedding = padEmbedding;
|
|
393
733
|
exports.prepareTextForEmbedding = prepareTextForEmbedding;
|
|
394
734
|
exports.search = search;
|
|
735
|
+
exports.validateEmbeddingBatch = validateEmbeddingBatch;
|
package/dist/index.d.ts
CHANGED
|
@@ -1,14 +1,56 @@
|
|
|
1
1
|
import { Client } from '@libsql/client';
|
|
2
2
|
|
|
3
3
|
type EmbeddingProvider = 'local' | 'gemini' | 'openai';
|
|
4
|
+
type EmbeddingIntent = 'document' | 'query';
|
|
5
|
+
type EmbeddingBatchMode = 'native' | 'sequential';
|
|
6
|
+
interface EmbeddingBatchBehavior {
|
|
7
|
+
mode: EmbeddingBatchMode;
|
|
8
|
+
maxSize?: number;
|
|
9
|
+
}
|
|
10
|
+
interface EmbeddingProviderMetadata {
|
|
11
|
+
name: EmbeddingProvider;
|
|
12
|
+
model: string;
|
|
13
|
+
dimensions: number;
|
|
14
|
+
batch: EmbeddingBatchBehavior;
|
|
15
|
+
}
|
|
16
|
+
interface EmbeddingRequestOptions {
|
|
17
|
+
intent?: EmbeddingIntent;
|
|
18
|
+
signal?: AbortSignal;
|
|
19
|
+
}
|
|
20
|
+
interface EmbeddingProviderClient {
|
|
21
|
+
readonly metadata: EmbeddingProviderMetadata;
|
|
22
|
+
embed(texts: string[], options?: EmbeddingRequestOptions): Promise<EmbeddingBatchResult>;
|
|
23
|
+
}
|
|
24
|
+
interface EmbeddingBatchResult {
|
|
25
|
+
embeddings: number[][];
|
|
26
|
+
provider: EmbeddingProvider;
|
|
27
|
+
model: string;
|
|
28
|
+
dimensions: number;
|
|
29
|
+
intent: EmbeddingIntent;
|
|
30
|
+
}
|
|
4
31
|
interface EmbeddingOptions {
|
|
5
32
|
provider?: EmbeddingProvider;
|
|
6
33
|
apiKey?: string;
|
|
7
34
|
dimensions?: number;
|
|
8
35
|
maxLength?: number;
|
|
36
|
+
intent?: EmbeddingIntent;
|
|
37
|
+
timeoutMs?: number;
|
|
38
|
+
signal?: AbortSignal;
|
|
9
39
|
}
|
|
40
|
+
interface EmbeddingBatchItemResult {
|
|
41
|
+
embedding: number[];
|
|
42
|
+
index?: unknown;
|
|
43
|
+
}
|
|
44
|
+
type EmbeddingBatchItem = number[] | EmbeddingBatchItemResult;
|
|
45
|
+
declare function validateEmbeddingBatch(items: EmbeddingBatchItem[], expectedCount: number, expectedDimensions: number, provider: EmbeddingProvider): number[][];
|
|
46
|
+
declare function createEmbeddingProvider(options?: EmbeddingOptions): EmbeddingProviderClient;
|
|
47
|
+
declare function getEmbeddingProviderMetadata(options?: EmbeddingOptions): EmbeddingProviderMetadata;
|
|
48
|
+
/**
|
|
49
|
+
* Generate embeddings using the specified provider.
|
|
50
|
+
*/
|
|
51
|
+
declare function generateEmbeddings(texts: string[], options?: EmbeddingOptions): Promise<number[][]>;
|
|
10
52
|
/**
|
|
11
|
-
* Generate
|
|
53
|
+
* Generate an embedding using the specified provider.
|
|
12
54
|
*/
|
|
13
55
|
declare function generateEmbedding(text: string, options?: EmbeddingOptions): Promise<number[]>;
|
|
14
56
|
/**
|
|
@@ -23,7 +65,7 @@ declare function prepareTextForEmbedding(fields: {
|
|
|
23
65
|
description?: string;
|
|
24
66
|
content?: string;
|
|
25
67
|
tags?: string[];
|
|
26
|
-
[key: string]:
|
|
68
|
+
[key: string]: unknown;
|
|
27
69
|
}): string;
|
|
28
70
|
|
|
29
71
|
/**
|
|
@@ -126,5 +168,5 @@ declare function getArticlesByFolder(client: Client, folder: string, tableName?:
|
|
|
126
168
|
*/
|
|
127
169
|
declare function getFolders(client: Client, tableName?: string): Promise<string[]>;
|
|
128
170
|
|
|
129
|
-
export { createTable, generateEmbedding, getAllArticles, getArticleBySlug, getArticlesByFolder, getFolders, indexContent, padEmbedding, prepareTextForEmbedding, search };
|
|
130
|
-
export type { EmbeddingOptions, EmbeddingProvider, IndexedDocument, IndexerOptions, SearchOptions, SearchResult };
|
|
171
|
+
export { createEmbeddingProvider, createTable, generateEmbedding, generateEmbeddings, getAllArticles, getArticleBySlug, getArticlesByFolder, getEmbeddingProviderMetadata, getFolders, indexContent, padEmbedding, prepareTextForEmbedding, search, validateEmbeddingBatch };
|
|
172
|
+
export type { EmbeddingBatchBehavior, EmbeddingBatchItem, EmbeddingBatchItemResult, EmbeddingBatchMode, EmbeddingBatchResult, EmbeddingIntent, EmbeddingOptions, EmbeddingProvider, EmbeddingProviderClient, EmbeddingProviderMetadata, EmbeddingRequestOptions, IndexedDocument, IndexerOptions, SearchOptions, SearchResult };
|
package/dist/index.esm.js
CHANGED
|
@@ -2,7 +2,15 @@ import { readdir, readFile } from 'fs/promises';
|
|
|
2
2
|
import { join, extname, relative, dirname } from 'path';
|
|
3
3
|
import matter from 'gray-matter';
|
|
4
4
|
|
|
5
|
-
const
|
|
5
|
+
const DEFAULT_DIMENSIONS = 768;
|
|
6
|
+
const DEFAULT_MAX_LENGTH = 8e3;
|
|
7
|
+
const DEFAULT_TIMEOUT_MS = 3e4;
|
|
8
|
+
const LOCAL_MODEL = "Xenova/all-MiniLM-L6-v2";
|
|
9
|
+
const GEMINI_MODEL = "text-embedding-004";
|
|
10
|
+
const OPENAI_SMALL_MODEL = "text-embedding-3-small";
|
|
11
|
+
const OPENAI_LARGE_MODEL = "text-embedding-3-large";
|
|
12
|
+
const OPENAI_EMBEDDINGS_URL = "https://api.openai.com/v1/embeddings";
|
|
13
|
+
const localModelCacheByModel = /* @__PURE__ */ new Map();
|
|
6
14
|
function getEnvironmentVariable(name) {
|
|
7
15
|
const runtime = globalThis;
|
|
8
16
|
const nodeValue = runtime.process?.env?.[name];
|
|
@@ -15,80 +23,403 @@ function getEnvironmentVariable(name) {
|
|
|
15
23
|
return void 0;
|
|
16
24
|
}
|
|
17
25
|
}
|
|
18
|
-
async function getLocalEmbeddingModel() {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
providerCache.local = await pipeline("feature-extraction", "Xenova/all-MiniLM-L6-v2");
|
|
23
|
-
console.log("Local model loaded successfully");
|
|
26
|
+
async function getLocalEmbeddingModel(modelName) {
|
|
27
|
+
const cached = localModelCacheByModel.get(modelName);
|
|
28
|
+
if (cached?.model) {
|
|
29
|
+
return cached.model;
|
|
24
30
|
}
|
|
25
|
-
|
|
31
|
+
console.log(`Loading local embedding model (${modelName})...`);
|
|
32
|
+
const { pipeline } = await import('@xenova/transformers');
|
|
33
|
+
const model = await pipeline("feature-extraction", modelName);
|
|
34
|
+
localModelCacheByModel.set(modelName, { model });
|
|
35
|
+
console.log("Local model loaded successfully");
|
|
36
|
+
return model;
|
|
26
37
|
}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
38
|
+
function getPositiveInteger(value, optionName) {
|
|
39
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
40
|
+
throw new Error(`Invalid ${optionName}: expected a positive integer`);
|
|
41
|
+
}
|
|
42
|
+
return value;
|
|
43
|
+
}
|
|
44
|
+
function getTimeoutMs(value) {
|
|
45
|
+
return getPositiveInteger(value ?? DEFAULT_TIMEOUT_MS, "timeoutMs");
|
|
46
|
+
}
|
|
47
|
+
function resolveProviderName(provider) {
|
|
48
|
+
switch (provider ?? "local") {
|
|
36
49
|
case "local":
|
|
37
|
-
return generateLocalEmbedding(truncated, dimensions);
|
|
38
50
|
case "gemini":
|
|
39
|
-
return generateGeminiEmbedding(truncated, apiKey);
|
|
40
51
|
case "openai":
|
|
41
|
-
return
|
|
52
|
+
return provider ?? "local";
|
|
53
|
+
default:
|
|
54
|
+
throw new Error(`Unknown embedding provider: ${String(provider)}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function resolveIntent(intent) {
|
|
58
|
+
switch (intent ?? "document") {
|
|
59
|
+
case "document":
|
|
60
|
+
case "query":
|
|
61
|
+
return intent ?? "document";
|
|
42
62
|
default:
|
|
43
|
-
throw new Error(`Unknown embedding
|
|
63
|
+
throw new Error(`Unknown embedding intent: ${String(intent)}`);
|
|
44
64
|
}
|
|
45
65
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
66
|
+
function truncateTexts(texts, maxLength) {
|
|
67
|
+
return texts.map((text) => text.substring(0, maxLength));
|
|
68
|
+
}
|
|
69
|
+
function getOpenAIModel(dimensions) {
|
|
70
|
+
return dimensions <= 1536 ? OPENAI_SMALL_MODEL : OPENAI_LARGE_MODEL;
|
|
71
|
+
}
|
|
72
|
+
function hasOwnProperty(value, property) {
|
|
73
|
+
return Object.prototype.hasOwnProperty.call(value, property);
|
|
74
|
+
}
|
|
75
|
+
function redactErrorText(value) {
|
|
76
|
+
const raw = value instanceof Error ? value.message : String(value);
|
|
77
|
+
return raw.replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer [redacted]").replace(/([?&](?:api[_-]?key|key|token)=)[^&\s]+/gi, "$1[redacted]").replace(/(authorization\s*[:=]\s*)[^\s,}]+/gi, "$1[redacted]").replace(/https?:\/\/[^\s)]+/gi, (match) => {
|
|
78
|
+
try {
|
|
79
|
+
const url = new URL(match);
|
|
80
|
+
return `${url.origin}${url.pathname}`;
|
|
81
|
+
} catch {
|
|
82
|
+
return "[redacted-url]";
|
|
83
|
+
}
|
|
84
|
+
}).slice(0, 300);
|
|
85
|
+
}
|
|
86
|
+
function providerError(provider, message, cause) {
|
|
87
|
+
const safeCause = cause === void 0 ? "" : `: ${redactErrorText(cause).trim()}`;
|
|
88
|
+
return new Error(`${provider} embedding error: ${message}${safeCause}`);
|
|
89
|
+
}
|
|
90
|
+
function getResponseHeader(response, name) {
|
|
91
|
+
return response.headers.get(name) ?? void 0;
|
|
92
|
+
}
|
|
93
|
+
async function withTimeout(provider, operation, timeoutMs, run, parentSignal) {
|
|
94
|
+
if (parentSignal?.aborted) {
|
|
95
|
+
throw providerError(provider, `${operation} was aborted`);
|
|
96
|
+
}
|
|
97
|
+
const controller = new AbortController();
|
|
98
|
+
let abortError;
|
|
99
|
+
let rejectAbort = () => {
|
|
100
|
+
};
|
|
101
|
+
const abortPromise = new Promise((_resolve, reject) => {
|
|
102
|
+
rejectAbort = reject;
|
|
51
103
|
});
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
}
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
104
|
+
const abort = (error) => {
|
|
105
|
+
if (abortError) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
abortError = error;
|
|
109
|
+
controller.abort();
|
|
110
|
+
rejectAbort(error);
|
|
111
|
+
};
|
|
112
|
+
const onParentAbort = () => abort(providerError(provider, `${operation} was aborted`));
|
|
113
|
+
const timeout = setTimeout(() => {
|
|
114
|
+
abort(providerError(provider, `${operation} timed out after ${timeoutMs}ms`));
|
|
115
|
+
}, timeoutMs);
|
|
116
|
+
parentSignal?.addEventListener("abort", onParentAbort, { once: true });
|
|
117
|
+
const operationPromise = Promise.resolve().then(() => run(controller.signal));
|
|
118
|
+
operationPromise.catch(() => void 0);
|
|
119
|
+
try {
|
|
120
|
+
return await Promise.race([operationPromise, abortPromise]);
|
|
121
|
+
} catch (error) {
|
|
122
|
+
if (error === abortError) {
|
|
123
|
+
throw error;
|
|
124
|
+
}
|
|
125
|
+
throw providerError(provider, `${operation} failed`, error);
|
|
126
|
+
} finally {
|
|
127
|
+
clearTimeout(timeout);
|
|
128
|
+
parentSignal?.removeEventListener("abort", onParentAbort);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
function validateEmbeddingBatch(items, expectedCount, expectedDimensions, provider) {
|
|
132
|
+
if (items.length !== expectedCount) {
|
|
133
|
+
throw providerError(
|
|
134
|
+
provider,
|
|
135
|
+
`expected ${expectedCount} embedding result(s), received ${items.length}`
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
const hasIndexedItems = items.some((item) => !Array.isArray(item) && hasOwnProperty(item, "index"));
|
|
139
|
+
const ordered = hasIndexedItems ? reorderIndexedEmbeddings(items, expectedCount, provider) : items.map((item) => Array.isArray(item) ? item : item.embedding);
|
|
140
|
+
return ordered.map(
|
|
141
|
+
(embedding, itemIndex) => validateEmbeddingVector(embedding, expectedDimensions, provider, itemIndex)
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
function reorderIndexedEmbeddings(items, expectedCount, provider) {
|
|
145
|
+
const ordered = new Array(expectedCount);
|
|
146
|
+
const seen = /* @__PURE__ */ new Set();
|
|
147
|
+
for (const item of items) {
|
|
148
|
+
if (Array.isArray(item) || !hasOwnProperty(item, "index")) {
|
|
149
|
+
throw providerError(provider, "provider returned a partially indexed embedding batch");
|
|
150
|
+
}
|
|
151
|
+
if (typeof item.index !== "number" || !Number.isInteger(item.index) || item.index < 0 || item.index >= expectedCount) {
|
|
152
|
+
throw providerError(provider, `provider returned invalid embedding index ${String(item.index)}`);
|
|
153
|
+
}
|
|
154
|
+
if (seen.has(item.index)) {
|
|
155
|
+
throw providerError(provider, `provider returned duplicate embedding index ${item.index}`);
|
|
156
|
+
}
|
|
157
|
+
seen.add(item.index);
|
|
158
|
+
ordered[item.index] = item.embedding;
|
|
159
|
+
}
|
|
160
|
+
if (seen.size !== expectedCount) {
|
|
161
|
+
throw providerError(provider, "provider returned non-contiguous embedding indices");
|
|
162
|
+
}
|
|
163
|
+
return ordered;
|
|
164
|
+
}
|
|
165
|
+
function validateEmbeddingVector(embedding, expectedDimensions, provider, itemIndex) {
|
|
166
|
+
if (!Array.isArray(embedding)) {
|
|
167
|
+
throw providerError(provider, `embedding ${itemIndex} is not an array`);
|
|
168
|
+
}
|
|
169
|
+
if (embedding.length !== expectedDimensions) {
|
|
170
|
+
throw providerError(
|
|
171
|
+
provider,
|
|
172
|
+
`embedding ${itemIndex} has ${embedding.length} dimensions, expected ${expectedDimensions}`
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
for (let i = 0; i < embedding.length; i++) {
|
|
176
|
+
const value = embedding[i];
|
|
177
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
178
|
+
throw providerError(provider, `embedding ${itemIndex} contains a non-finite value at dimension ${i}`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return embedding;
|
|
182
|
+
}
|
|
183
|
+
function createProviderMetadata(provider, dimensions) {
|
|
184
|
+
switch (provider) {
|
|
185
|
+
case "local":
|
|
186
|
+
return Object.freeze({
|
|
187
|
+
name: "local",
|
|
188
|
+
model: LOCAL_MODEL,
|
|
189
|
+
dimensions,
|
|
190
|
+
batch: Object.freeze({ mode: "sequential" })
|
|
191
|
+
});
|
|
192
|
+
case "gemini":
|
|
193
|
+
return Object.freeze({
|
|
194
|
+
name: "gemini",
|
|
195
|
+
model: GEMINI_MODEL,
|
|
196
|
+
dimensions: DEFAULT_DIMENSIONS,
|
|
197
|
+
batch: Object.freeze({ mode: "sequential" })
|
|
198
|
+
});
|
|
199
|
+
case "openai":
|
|
200
|
+
return Object.freeze({
|
|
201
|
+
name: "openai",
|
|
202
|
+
model: getOpenAIModel(dimensions),
|
|
203
|
+
dimensions,
|
|
204
|
+
batch: Object.freeze({ mode: "native", maxSize: 2048 })
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
function getEffectiveDimensions(provider, dimensions) {
|
|
209
|
+
if (provider === "gemini") {
|
|
210
|
+
return DEFAULT_DIMENSIONS;
|
|
211
|
+
}
|
|
212
|
+
return getPositiveInteger(dimensions ?? DEFAULT_DIMENSIONS, "dimensions");
|
|
213
|
+
}
|
|
214
|
+
function assertBatchSize(metadata, count) {
|
|
215
|
+
if (metadata.batch.maxSize !== void 0 && count > metadata.batch.maxSize) {
|
|
216
|
+
throw providerError(
|
|
217
|
+
metadata.name,
|
|
218
|
+
`batch size ${count} exceeds maximum ${metadata.batch.maxSize}`
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
function createEmbeddingBatchResult(metadata, intent, embeddings) {
|
|
223
|
+
return Object.freeze({
|
|
224
|
+
embeddings,
|
|
225
|
+
provider: metadata.name,
|
|
226
|
+
model: metadata.model,
|
|
227
|
+
dimensions: metadata.dimensions,
|
|
228
|
+
intent
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
class LocalEmbeddingProvider {
|
|
232
|
+
metadata;
|
|
233
|
+
#timeoutMs;
|
|
234
|
+
constructor(metadata, timeoutMs) {
|
|
235
|
+
this.metadata = metadata;
|
|
236
|
+
this.#timeoutMs = timeoutMs;
|
|
237
|
+
}
|
|
238
|
+
async embed(texts, options = {}) {
|
|
239
|
+
const intent = resolveIntent(options.intent);
|
|
240
|
+
if (texts.length === 0) {
|
|
241
|
+
return createEmbeddingBatchResult(this.metadata, intent, []);
|
|
242
|
+
}
|
|
243
|
+
assertBatchSize(this.metadata, texts.length);
|
|
244
|
+
const vectors = await withTimeout(
|
|
245
|
+
"local",
|
|
246
|
+
"model inference",
|
|
247
|
+
this.#timeoutMs,
|
|
248
|
+
async () => {
|
|
249
|
+
const model = await getLocalEmbeddingModel(this.metadata.model);
|
|
250
|
+
const results = [];
|
|
251
|
+
for (const text of texts) {
|
|
252
|
+
const output = await model(text, {
|
|
253
|
+
pooling: "mean",
|
|
254
|
+
normalize: true
|
|
255
|
+
});
|
|
256
|
+
const embedding = Array.from(output.data);
|
|
257
|
+
results.push(padEmbedding(embedding, this.metadata.dimensions));
|
|
258
|
+
}
|
|
259
|
+
return results;
|
|
260
|
+
},
|
|
261
|
+
options.signal
|
|
262
|
+
);
|
|
263
|
+
return createEmbeddingBatchResult(
|
|
264
|
+
this.metadata,
|
|
265
|
+
intent,
|
|
266
|
+
validateEmbeddingBatch(vectors, texts.length, this.metadata.dimensions, "local")
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
class GeminiEmbeddingProvider {
|
|
271
|
+
metadata;
|
|
272
|
+
#apiKey;
|
|
273
|
+
#timeoutMs;
|
|
274
|
+
constructor(metadata, apiKey, timeoutMs) {
|
|
275
|
+
this.metadata = metadata;
|
|
276
|
+
this.#apiKey = apiKey;
|
|
277
|
+
this.#timeoutMs = timeoutMs;
|
|
278
|
+
}
|
|
279
|
+
async embed(texts, options = {}) {
|
|
280
|
+
const intent = resolveIntent(options.intent);
|
|
281
|
+
if (texts.length === 0) {
|
|
282
|
+
return createEmbeddingBatchResult(this.metadata, intent, []);
|
|
283
|
+
}
|
|
284
|
+
assertBatchSize(this.metadata, texts.length);
|
|
285
|
+
const vectors = await withTimeout(
|
|
286
|
+
"gemini",
|
|
287
|
+
"API request",
|
|
288
|
+
this.#timeoutMs,
|
|
289
|
+
async () => {
|
|
290
|
+
const { GoogleGenerativeAI } = await import('@google/generative-ai');
|
|
291
|
+
const genAI = new GoogleGenerativeAI(this.#apiKey);
|
|
292
|
+
const model = genAI.getGenerativeModel({ model: this.metadata.model });
|
|
293
|
+
const results = [];
|
|
294
|
+
for (const text of texts) {
|
|
295
|
+
const result = await model.embedContent(text);
|
|
296
|
+
const values = result.embedding?.values;
|
|
297
|
+
if (!Array.isArray(values)) {
|
|
298
|
+
throw new Error("Gemini response did not include embedding values");
|
|
299
|
+
}
|
|
300
|
+
results.push(values);
|
|
301
|
+
}
|
|
302
|
+
return results;
|
|
303
|
+
},
|
|
304
|
+
options.signal
|
|
305
|
+
);
|
|
306
|
+
return createEmbeddingBatchResult(
|
|
307
|
+
this.metadata,
|
|
308
|
+
intent,
|
|
309
|
+
validateEmbeddingBatch(vectors, texts.length, this.metadata.dimensions, "gemini")
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
class OpenAIEmbeddingProvider {
|
|
314
|
+
metadata;
|
|
315
|
+
#apiKey;
|
|
316
|
+
#timeoutMs;
|
|
317
|
+
constructor(metadata, apiKey, timeoutMs) {
|
|
318
|
+
this.metadata = metadata;
|
|
319
|
+
this.#apiKey = apiKey;
|
|
320
|
+
this.#timeoutMs = timeoutMs;
|
|
321
|
+
}
|
|
322
|
+
async embed(texts, options = {}) {
|
|
323
|
+
const intent = resolveIntent(options.intent);
|
|
324
|
+
if (texts.length === 0) {
|
|
325
|
+
return createEmbeddingBatchResult(this.metadata, intent, []);
|
|
326
|
+
}
|
|
327
|
+
assertBatchSize(this.metadata, texts.length);
|
|
328
|
+
const items = await withTimeout(
|
|
329
|
+
"openai",
|
|
330
|
+
"API request",
|
|
331
|
+
this.#timeoutMs,
|
|
332
|
+
async (signal) => {
|
|
333
|
+
const response = await fetch(OPENAI_EMBEDDINGS_URL, {
|
|
334
|
+
method: "POST",
|
|
335
|
+
headers: {
|
|
336
|
+
"Content-Type": "application/json",
|
|
337
|
+
"Authorization": `Bearer ${this.#apiKey}`
|
|
338
|
+
},
|
|
339
|
+
signal,
|
|
340
|
+
body: JSON.stringify({
|
|
341
|
+
input: texts,
|
|
342
|
+
model: this.metadata.model,
|
|
343
|
+
dimensions: this.metadata.dimensions
|
|
344
|
+
})
|
|
345
|
+
});
|
|
346
|
+
if (!response.ok) {
|
|
347
|
+
const requestId = getResponseHeader(response, "x-request-id");
|
|
348
|
+
const context = requestId ? ` status ${response.status}, request ${requestId}` : ` status ${response.status}`;
|
|
349
|
+
throw new Error(context);
|
|
350
|
+
}
|
|
351
|
+
const data = await response.json();
|
|
352
|
+
if (!Array.isArray(data.data)) {
|
|
353
|
+
throw new Error("OpenAI response did not include a data array");
|
|
354
|
+
}
|
|
355
|
+
return data.data.map((item) => {
|
|
356
|
+
const typed = item;
|
|
357
|
+
const embedding = typed.embedding;
|
|
358
|
+
if (!Array.isArray(embedding)) {
|
|
359
|
+
throw new Error("OpenAI response item did not include an embedding array");
|
|
360
|
+
}
|
|
361
|
+
const parsed = { embedding };
|
|
362
|
+
if (hasOwnProperty(typed, "index")) {
|
|
363
|
+
parsed.index = typed.index;
|
|
364
|
+
}
|
|
365
|
+
return parsed;
|
|
366
|
+
});
|
|
367
|
+
},
|
|
368
|
+
options.signal
|
|
369
|
+
);
|
|
370
|
+
return createEmbeddingBatchResult(
|
|
371
|
+
this.metadata,
|
|
372
|
+
intent,
|
|
373
|
+
validateEmbeddingBatch(items, texts.length, this.metadata.dimensions, "openai")
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
function createEmbeddingProvider(options = {}) {
|
|
378
|
+
const provider = resolveProviderName(options.provider);
|
|
379
|
+
const metadata = getEmbeddingProviderMetadata(options);
|
|
380
|
+
const timeoutMs = getTimeoutMs(options.timeoutMs);
|
|
381
|
+
switch (provider) {
|
|
382
|
+
case "local":
|
|
383
|
+
return new LocalEmbeddingProvider(metadata, timeoutMs);
|
|
384
|
+
case "gemini": {
|
|
385
|
+
const key = options.apiKey || getEnvironmentVariable("GEMINI_API_KEY");
|
|
386
|
+
if (!key) {
|
|
387
|
+
throw new Error("GEMINI_API_KEY is required for Gemini embeddings");
|
|
388
|
+
}
|
|
389
|
+
return new GeminiEmbeddingProvider(metadata, key, timeoutMs);
|
|
390
|
+
}
|
|
391
|
+
case "openai": {
|
|
392
|
+
const key = options.apiKey || getEnvironmentVariable("OPENAI_API_KEY");
|
|
393
|
+
if (!key) {
|
|
394
|
+
throw new Error("OPENAI_API_KEY is required for OpenAI embeddings");
|
|
395
|
+
}
|
|
396
|
+
return new OpenAIEmbeddingProvider(metadata, key, timeoutMs);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
function getEmbeddingProviderMetadata(options = {}) {
|
|
401
|
+
const provider = resolveProviderName(options.provider);
|
|
402
|
+
return createProviderMetadata(provider, getEffectiveDimensions(provider, options.dimensions));
|
|
403
|
+
}
|
|
404
|
+
async function generateEmbeddings(texts, options = {}) {
|
|
405
|
+
const maxLength = getPositiveInteger(options.maxLength ?? DEFAULT_MAX_LENGTH, "maxLength");
|
|
406
|
+
const intent = resolveIntent(options.intent);
|
|
407
|
+
if (texts.length === 0) {
|
|
408
|
+
return [];
|
|
409
|
+
}
|
|
410
|
+
const provider = createEmbeddingProvider(options);
|
|
411
|
+
const result = await provider.embed(truncateTexts(texts, maxLength), {
|
|
412
|
+
intent,
|
|
413
|
+
...options.signal ? { signal: options.signal } : {}
|
|
85
414
|
});
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
415
|
+
return result.embeddings;
|
|
416
|
+
}
|
|
417
|
+
async function generateEmbedding(text, options = {}) {
|
|
418
|
+
const [embedding] = await generateEmbeddings([text], options);
|
|
419
|
+
if (!embedding) {
|
|
420
|
+
throw new Error("Embedding provider returned no embedding");
|
|
89
421
|
}
|
|
90
|
-
|
|
91
|
-
return data.data[0].embedding;
|
|
422
|
+
return embedding;
|
|
92
423
|
}
|
|
93
424
|
function padEmbedding(embedding, targetDimensions) {
|
|
94
425
|
if (embedding.length === targetDimensions) {
|
|
@@ -209,7 +540,10 @@ async function processFile(file, embeddingOptions) {
|
|
|
209
540
|
content: markdown,
|
|
210
541
|
tags
|
|
211
542
|
});
|
|
212
|
-
const embedding = await generateEmbedding(embeddingText,
|
|
543
|
+
const embedding = await generateEmbedding(embeddingText, {
|
|
544
|
+
...embeddingOptions,
|
|
545
|
+
intent: embeddingOptions.intent ?? "document"
|
|
546
|
+
});
|
|
213
547
|
return {
|
|
214
548
|
slug,
|
|
215
549
|
title,
|
|
@@ -278,7 +612,10 @@ async function search(options) {
|
|
|
278
612
|
} = options;
|
|
279
613
|
const quotedTableName = quoteSqlIdentifier(tableName, "tableName");
|
|
280
614
|
const resultLimit = normalizeSearchLimit(limit);
|
|
281
|
-
const queryEmbedding = await generateEmbedding(query,
|
|
615
|
+
const queryEmbedding = await generateEmbedding(query, {
|
|
616
|
+
...embeddingOptions,
|
|
617
|
+
intent: embeddingOptions.intent ?? "query"
|
|
618
|
+
});
|
|
282
619
|
const results = await client.execute({
|
|
283
620
|
sql: `
|
|
284
621
|
SELECT
|
|
@@ -380,4 +717,4 @@ async function getFolders(client, tableName = "articles") {
|
|
|
380
717
|
return results.rows.map((row) => row.folder);
|
|
381
718
|
}
|
|
382
719
|
|
|
383
|
-
export { createTable, generateEmbedding, getAllArticles, getArticleBySlug, getArticlesByFolder, getFolders, indexContent, padEmbedding, prepareTextForEmbedding, search };
|
|
720
|
+
export { createEmbeddingProvider, createTable, generateEmbedding, generateEmbeddings, getAllArticles, getArticleBySlug, getArticlesByFolder, getEmbeddingProviderMetadata, getFolders, indexContent, padEmbedding, prepareTextForEmbedding, search, validateEmbeddingBatch };
|
package/docs/API.md
CHANGED
|
@@ -12,12 +12,25 @@
|
|
|
12
12
|
- `getArticlesByFolder`
|
|
13
13
|
- `getFolders`
|
|
14
14
|
- `generateEmbedding`
|
|
15
|
+
- `generateEmbeddings`
|
|
16
|
+
- `createEmbeddingProvider`
|
|
17
|
+
- `getEmbeddingProviderMetadata`
|
|
18
|
+
- `validateEmbeddingBatch`
|
|
15
19
|
- `padEmbedding`
|
|
16
20
|
- `prepareTextForEmbedding`
|
|
17
21
|
|
|
18
22
|
It also exports these types:
|
|
19
23
|
|
|
20
24
|
- `EmbeddingProvider`
|
|
25
|
+
- `EmbeddingIntent`
|
|
26
|
+
- `EmbeddingBatchMode`
|
|
27
|
+
- `EmbeddingBatchBehavior`
|
|
28
|
+
- `EmbeddingProviderMetadata`
|
|
29
|
+
- `EmbeddingRequestOptions`
|
|
30
|
+
- `EmbeddingProviderClient`
|
|
31
|
+
- `EmbeddingBatchResult`
|
|
32
|
+
- `EmbeddingBatchItemResult`
|
|
33
|
+
- `EmbeddingBatchItem`
|
|
21
34
|
- `EmbeddingOptions`
|
|
22
35
|
- `IndexerOptions`
|
|
23
36
|
- `IndexedDocument`
|
|
@@ -93,6 +106,8 @@ Behavior notes:
|
|
|
93
106
|
- `indexContent()` deletes existing rows in the target table before rebuilding
|
|
94
107
|
- frontmatter `title`, `description`, and `tags` are folded into the embedding
|
|
95
108
|
text
|
|
109
|
+
- embeddings default to `intent: "document"` unless `embeddingOptions.intent`
|
|
110
|
+
is set explicitly
|
|
96
111
|
- if a file has no frontmatter title, the filename becomes the title
|
|
97
112
|
|
|
98
113
|
## `search(options)`
|
|
@@ -135,6 +150,9 @@ interface SearchResult {
|
|
|
135
150
|
|
|
136
151
|
Lower `distance` values are better matches.
|
|
137
152
|
|
|
153
|
+
Search embeddings default to `intent: "query"` unless
|
|
154
|
+
`embeddingOptions.intent` is set explicitly.
|
|
155
|
+
|
|
138
156
|
## Article Retrieval Helpers
|
|
139
157
|
|
|
140
158
|
### `getAllArticles(client, tableName?)`
|
|
@@ -161,6 +179,91 @@ All article retrieval helpers validate `tableName` before executing SQL.
|
|
|
161
179
|
|
|
162
180
|
Generates an embedding for arbitrary text using the selected provider.
|
|
163
181
|
|
|
182
|
+
### `generateEmbeddings(texts, options?)`
|
|
183
|
+
|
|
184
|
+
Generates an ordered batch of embeddings. Empty batches return `[]` without
|
|
185
|
+
creating a hosted provider client or making a network request.
|
|
186
|
+
|
|
187
|
+
### `createEmbeddingProvider(options?)`
|
|
188
|
+
|
|
189
|
+
Creates a provider client with immutable metadata and an `embed(texts, options?)`
|
|
190
|
+
method. Provider clients return a rich `EmbeddingBatchResult`; the compatibility
|
|
191
|
+
helpers `generateEmbedding()` and `generateEmbeddings()` continue returning only
|
|
192
|
+
vectors.
|
|
193
|
+
|
|
194
|
+
```ts
|
|
195
|
+
const provider = createEmbeddingProvider({
|
|
196
|
+
provider: "openai",
|
|
197
|
+
apiKey: process.env.OPENAI_API_KEY,
|
|
198
|
+
dimensions: 1536,
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
console.log(provider.metadata);
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
Provider metadata includes:
|
|
205
|
+
|
|
206
|
+
- `name`
|
|
207
|
+
- `model`
|
|
208
|
+
- `dimensions`
|
|
209
|
+
- `batch.mode`
|
|
210
|
+
- `batch.maxSize`, when the provider has a hard maximum
|
|
211
|
+
|
|
212
|
+
Hosted provider clients are scoped to their options. The library does not reuse
|
|
213
|
+
a Gemini or OpenAI client created with a different API key or configuration.
|
|
214
|
+
|
|
215
|
+
### `getEmbeddingProviderMetadata(options?)`
|
|
216
|
+
|
|
217
|
+
Returns the same metadata exposed by `createEmbeddingProvider(options).metadata`
|
|
218
|
+
without resolving hosted-provider credentials.
|
|
219
|
+
|
|
220
|
+
Provider batch metadata uses:
|
|
221
|
+
|
|
222
|
+
```ts
|
|
223
|
+
type EmbeddingBatchMode = "native" | "sequential";
|
|
224
|
+
|
|
225
|
+
interface EmbeddingBatchBehavior {
|
|
226
|
+
mode: EmbeddingBatchMode;
|
|
227
|
+
maxSize?: number;
|
|
228
|
+
}
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
`"native"` means the upstream provider accepts the batch in one request.
|
|
232
|
+
`"sequential"` means the library accepts an input batch but processes items one
|
|
233
|
+
at a time. If `maxSize` is present, the library enforces it before provider or
|
|
234
|
+
network work.
|
|
235
|
+
|
|
236
|
+
Provider clients return:
|
|
237
|
+
|
|
238
|
+
```ts
|
|
239
|
+
interface EmbeddingBatchResult {
|
|
240
|
+
embeddings: number[][];
|
|
241
|
+
provider: "local" | "gemini" | "openai";
|
|
242
|
+
model: string;
|
|
243
|
+
dimensions: number;
|
|
244
|
+
intent: "document" | "query";
|
|
245
|
+
}
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
### `validateEmbeddingBatch(items, expectedCount, expectedDimensions, provider)`
|
|
249
|
+
|
|
250
|
+
Validates provider results before they are written to the database. It checks
|
|
251
|
+
cardinality, dimensions, finite numeric values, and indexed batch ordering.
|
|
252
|
+
|
|
253
|
+
`EmbeddingOptions` supports:
|
|
254
|
+
|
|
255
|
+
```ts
|
|
256
|
+
interface EmbeddingOptions {
|
|
257
|
+
provider?: "local" | "gemini" | "openai";
|
|
258
|
+
apiKey?: string;
|
|
259
|
+
dimensions?: number;
|
|
260
|
+
maxLength?: number;
|
|
261
|
+
intent?: "document" | "query";
|
|
262
|
+
timeoutMs?: number;
|
|
263
|
+
signal?: AbortSignal;
|
|
264
|
+
}
|
|
265
|
+
```
|
|
266
|
+
|
|
164
267
|
### `padEmbedding(embedding, targetDimensions)`
|
|
165
268
|
|
|
166
269
|
Pads or truncates an embedding array to the requested length.
|
package/docs/PROVIDERS.md
CHANGED
|
@@ -18,15 +18,74 @@ interface EmbeddingOptions {
|
|
|
18
18
|
apiKey?: string;
|
|
19
19
|
dimensions?: number;
|
|
20
20
|
maxLength?: number;
|
|
21
|
+
intent?: "document" | "query";
|
|
22
|
+
timeoutMs?: number;
|
|
23
|
+
signal?: AbortSignal;
|
|
21
24
|
}
|
|
22
25
|
```
|
|
23
26
|
|
|
24
27
|
- `provider` defaults to `"local"`
|
|
25
28
|
- `dimensions` defaults to `768`
|
|
26
29
|
- `maxLength` defaults to `8000`
|
|
30
|
+
- `intent` can be `"document"` or `"query"`; indexing defaults to
|
|
31
|
+
`"document"` and search defaults to `"query"` unless explicitly set
|
|
32
|
+
- `timeoutMs` defaults to `30000`
|
|
27
33
|
- `apiKey` is optional in code, but required for hosted providers unless the
|
|
28
34
|
matching environment variable is available
|
|
29
35
|
|
|
36
|
+
## Provider Contract
|
|
37
|
+
|
|
38
|
+
Each provider exposes immutable metadata:
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
interface EmbeddingProviderMetadata {
|
|
42
|
+
name: "local" | "gemini" | "openai";
|
|
43
|
+
model: string;
|
|
44
|
+
dimensions: number;
|
|
45
|
+
batch: {
|
|
46
|
+
mode: "native" | "sequential";
|
|
47
|
+
maxSize?: number;
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Use `getEmbeddingProviderMetadata(options)` or
|
|
53
|
+
`createEmbeddingProvider(options).metadata` to inspect the effective model,
|
|
54
|
+
dimensions, and batch behavior. Metadata inspection does not require hosted
|
|
55
|
+
provider credentials.
|
|
56
|
+
|
|
57
|
+
Batch modes:
|
|
58
|
+
|
|
59
|
+
- `"native"` means the upstream provider accepts the batch in one request
|
|
60
|
+
- `"sequential"` means the library accepts a batch and processes items one at a
|
|
61
|
+
time
|
|
62
|
+
- when `maxSize` is present, it is a hard maximum enforced before provider or
|
|
63
|
+
network work
|
|
64
|
+
|
|
65
|
+
`generateEmbeddings(texts, options)` returns vectors in the same order as the
|
|
66
|
+
input texts. Provider responses are validated before database writes:
|
|
67
|
+
|
|
68
|
+
- result count must match input count
|
|
69
|
+
- each vector must match the provider's effective dimensions
|
|
70
|
+
- every vector value must be a finite number
|
|
71
|
+
- indexed batch responses must contain unique contiguous indices and are
|
|
72
|
+
reordered before being returned
|
|
73
|
+
|
|
74
|
+
Empty batches return `[]` without loading a local model, creating hosted clients,
|
|
75
|
+
or making network calls.
|
|
76
|
+
|
|
77
|
+
Lower-level provider clients return an `EmbeddingBatchResult` with the validated
|
|
78
|
+
vectors plus provider, model, dimensions, and intent. The compatibility helpers
|
|
79
|
+
`generateEmbedding()` and `generateEmbeddings()` return only arrays.
|
|
80
|
+
|
|
81
|
+
Gemini and OpenAI clients are scoped to their current options. They are not
|
|
82
|
+
cached globally across different credentials or configurations. The local Xenova
|
|
83
|
+
model can be cached by model name.
|
|
84
|
+
|
|
85
|
+
Hosted provider failures are reported with bounded provider/status/request-id
|
|
86
|
+
context and without raw upstream bodies, credentials, Authorization headers, or
|
|
87
|
+
full URLs with query strings.
|
|
88
|
+
|
|
30
89
|
## Local
|
|
31
90
|
|
|
32
91
|
Provider value: `local`
|
|
@@ -45,6 +104,8 @@ Notes:
|
|
|
45
104
|
|
|
46
105
|
- the model emits 384 dimensions and `libsql-search` pads or truncates to your
|
|
47
106
|
requested size
|
|
107
|
+
- metadata reports the requested output dimensions
|
|
108
|
+
- batch metadata is `{ mode: "sequential" }`
|
|
48
109
|
- the first run downloads the model and can take longer on a fresh machine
|
|
49
110
|
- no API key is required
|
|
50
111
|
|
|
@@ -65,6 +126,8 @@ Behavior:
|
|
|
65
126
|
|
|
66
127
|
- if `apiKey` is omitted, the library reads `GEMINI_API_KEY`
|
|
67
128
|
- Gemini returns 768 dimensions natively
|
|
129
|
+
- metadata reports `text-embedding-004` and 768 dimensions
|
|
130
|
+
- batch metadata is `{ mode: "sequential" }`
|
|
68
131
|
- the current implementation does not expose model selection
|
|
69
132
|
|
|
70
133
|
## OpenAI
|
|
@@ -86,6 +149,9 @@ Behavior:
|
|
|
86
149
|
|
|
87
150
|
- if `apiKey` is omitted, the library reads `OPENAI_API_KEY`
|
|
88
151
|
- the request sends the `dimensions` value to the OpenAI embeddings API
|
|
152
|
+
- metadata reports `text-embedding-3-small` when `dimensions <= 1536` and
|
|
153
|
+
`text-embedding-3-large` when `dimensions > 1536`
|
|
154
|
+
- batch metadata is `{ mode: "native", maxSize: 2048 }`
|
|
89
155
|
- use the same dimension count in `createTable()`
|
|
90
156
|
|
|
91
157
|
## Dimension Guidelines
|