libsql-search 0.1.6 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 providerCache = {};
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,421 @@ function getEnvironmentVariable(name) {
15
23
  return void 0;
16
24
  }
17
25
  }
18
- async function getLocalEmbeddingModel() {
19
- if (!providerCache.local) {
20
- console.log("Loading local embedding model (Xenova/all-MiniLM-L6-v2)...");
21
- const { pipeline } = await import('@xenova/transformers');
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
- return providerCache.local;
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
- async function generateEmbedding(text, options = {}) {
28
- const {
29
- provider = "local",
30
- apiKey,
31
- dimensions = 768,
32
- maxLength = 8e3
33
- } = options;
34
- const truncated = text.substring(0, maxLength);
35
- switch (provider) {
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 generateOpenAIEmbedding(truncated, apiKey, dimensions);
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 provider: ${provider}`);
63
+ throw new Error(`Unknown embedding intent: ${String(intent)}`);
64
+ }
65
+ }
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, exactSecrets = []) {
76
+ let raw = value instanceof Error ? value.message : String(value);
77
+ for (const secret of exactSecrets) {
78
+ if (secret.length > 0) {
79
+ raw = raw.split(secret).join("[redacted]");
80
+ }
44
81
  }
82
+ 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) => {
83
+ try {
84
+ const url = new URL(match);
85
+ return `${url.origin}${url.pathname}`;
86
+ } catch {
87
+ return "[redacted-url]";
88
+ }
89
+ }).slice(0, 300);
45
90
  }
46
- async function generateLocalEmbedding(text, targetDimensions) {
47
- const model = await getLocalEmbeddingModel();
48
- const output = await model(text, {
49
- pooling: "mean",
50
- normalize: true
91
+ function providerError(provider, message, cause, exactSecrets = []) {
92
+ const safeCause = cause === void 0 ? "" : `: ${redactErrorText(cause, exactSecrets).trim()}`;
93
+ return new Error(`${provider} embedding error: ${message}${safeCause}`);
94
+ }
95
+ function getResponseHeader(response, name) {
96
+ return response.headers.get(name) ?? void 0;
97
+ }
98
+ async function withTimeout(provider, operation, timeoutMs, run, parentSignal, exactSecrets = []) {
99
+ if (parentSignal?.aborted) {
100
+ throw providerError(provider, `${operation} was aborted`);
101
+ }
102
+ const controller = new AbortController();
103
+ let abortError;
104
+ let rejectAbort = () => {
105
+ };
106
+ const abortPromise = new Promise((_resolve, reject) => {
107
+ rejectAbort = reject;
51
108
  });
52
- const embedding = Array.from(output.data);
53
- return padEmbedding(embedding, targetDimensions);
54
- }
55
- async function generateGeminiEmbedding(text, apiKey) {
56
- const key = apiKey || getEnvironmentVariable("GEMINI_API_KEY");
57
- if (!key) {
58
- throw new Error("GEMINI_API_KEY is required for Gemini embeddings");
59
- }
60
- if (!providerCache.gemini) {
61
- const { GoogleGenerativeAI } = await import('@google/generative-ai');
62
- const genAI = new GoogleGenerativeAI(key);
63
- providerCache.gemini = genAI.getGenerativeModel({ model: "text-embedding-004" });
64
- }
65
- const result = await providerCache.gemini.embedContent(text);
66
- return result.embedding.values;
67
- }
68
- async function generateOpenAIEmbedding(text, apiKey, dimensions = 1536) {
69
- const key = apiKey || getEnvironmentVariable("OPENAI_API_KEY");
70
- if (!key) {
71
- throw new Error("OPENAI_API_KEY is required for OpenAI embeddings");
72
- }
73
- const model = dimensions <= 1536 ? "text-embedding-3-small" : "text-embedding-3-large";
74
- const response = await fetch("https://api.openai.com/v1/embeddings", {
75
- method: "POST",
76
- headers: {
77
- "Content-Type": "application/json",
78
- "Authorization": `Bearer ${key}`
79
- },
80
- body: JSON.stringify({
81
- input: text,
82
- model,
83
- dimensions
84
- })
109
+ const abort = (error) => {
110
+ if (abortError) {
111
+ return;
112
+ }
113
+ abortError = error;
114
+ controller.abort();
115
+ rejectAbort(error);
116
+ };
117
+ const onParentAbort = () => abort(providerError(provider, `${operation} was aborted`));
118
+ const timeout = setTimeout(() => {
119
+ abort(providerError(provider, `${operation} timed out after ${timeoutMs}ms`));
120
+ }, timeoutMs);
121
+ parentSignal?.addEventListener("abort", onParentAbort, { once: true });
122
+ const operationPromise = Promise.resolve().then(() => run(controller.signal));
123
+ operationPromise.catch(() => void 0);
124
+ try {
125
+ return await Promise.race([operationPromise, abortPromise]);
126
+ } catch (error) {
127
+ if (error === abortError) {
128
+ throw error;
129
+ }
130
+ throw providerError(provider, `${operation} failed`, error, exactSecrets);
131
+ } finally {
132
+ clearTimeout(timeout);
133
+ parentSignal?.removeEventListener("abort", onParentAbort);
134
+ }
135
+ }
136
+ function validateEmbeddingBatch(items, expectedCount, expectedDimensions, provider) {
137
+ if (items.length !== expectedCount) {
138
+ throw providerError(
139
+ provider,
140
+ `expected ${expectedCount} embedding result(s), received ${items.length}`
141
+ );
142
+ }
143
+ const hasIndexedItems = items.some((item) => !Array.isArray(item) && hasOwnProperty(item, "index"));
144
+ const ordered = hasIndexedItems ? reorderIndexedEmbeddings(items, expectedCount, provider) : items.map((item) => Array.isArray(item) ? item : item.embedding);
145
+ return ordered.map(
146
+ (embedding, itemIndex) => validateEmbeddingVector(embedding, expectedDimensions, provider, itemIndex)
147
+ );
148
+ }
149
+ function reorderIndexedEmbeddings(items, expectedCount, provider) {
150
+ const ordered = new Array(expectedCount);
151
+ const seen = /* @__PURE__ */ new Set();
152
+ for (const item of items) {
153
+ if (Array.isArray(item) || !hasOwnProperty(item, "index")) {
154
+ throw providerError(provider, "provider returned a partially indexed embedding batch");
155
+ }
156
+ if (typeof item.index !== "number" || !Number.isInteger(item.index) || item.index < 0 || item.index >= expectedCount) {
157
+ throw providerError(provider, `provider returned invalid embedding index ${String(item.index)}`);
158
+ }
159
+ if (seen.has(item.index)) {
160
+ throw providerError(provider, `provider returned duplicate embedding index ${item.index}`);
161
+ }
162
+ seen.add(item.index);
163
+ ordered[item.index] = item.embedding;
164
+ }
165
+ if (seen.size !== expectedCount) {
166
+ throw providerError(provider, "provider returned non-contiguous embedding indices");
167
+ }
168
+ return ordered;
169
+ }
170
+ function validateEmbeddingVector(embedding, expectedDimensions, provider, itemIndex) {
171
+ if (!Array.isArray(embedding)) {
172
+ throw providerError(provider, `embedding ${itemIndex} is not an array`);
173
+ }
174
+ if (embedding.length !== expectedDimensions) {
175
+ throw providerError(
176
+ provider,
177
+ `embedding ${itemIndex} has ${embedding.length} dimensions, expected ${expectedDimensions}`
178
+ );
179
+ }
180
+ for (let i = 0; i < embedding.length; i++) {
181
+ const value = embedding[i];
182
+ if (typeof value !== "number" || !Number.isFinite(value)) {
183
+ throw providerError(provider, `embedding ${itemIndex} contains a non-finite value at dimension ${i}`);
184
+ }
185
+ }
186
+ return embedding;
187
+ }
188
+ function throwIfAborted(provider, operation, signal) {
189
+ if (signal.aborted) {
190
+ throw providerError(provider, `${operation} was aborted`);
191
+ }
192
+ }
193
+ async function embedSequentially(provider, operation, texts, signal, embedOne) {
194
+ const results = [];
195
+ for (const text of texts) {
196
+ throwIfAborted(provider, operation, signal);
197
+ const embedding = await embedOne(text, signal);
198
+ throwIfAborted(provider, operation, signal);
199
+ results.push(embedding);
200
+ }
201
+ return results;
202
+ }
203
+ function createProviderMetadata(provider, dimensions) {
204
+ switch (provider) {
205
+ case "local":
206
+ return Object.freeze({
207
+ name: "local",
208
+ model: LOCAL_MODEL,
209
+ dimensions,
210
+ batch: Object.freeze({ mode: "sequential" })
211
+ });
212
+ case "gemini":
213
+ return Object.freeze({
214
+ name: "gemini",
215
+ model: GEMINI_MODEL,
216
+ dimensions: DEFAULT_DIMENSIONS,
217
+ batch: Object.freeze({ mode: "sequential" })
218
+ });
219
+ case "openai":
220
+ return Object.freeze({
221
+ name: "openai",
222
+ model: getOpenAIModel(dimensions),
223
+ dimensions,
224
+ batch: Object.freeze({ mode: "native", maxSize: 2048 })
225
+ });
226
+ }
227
+ }
228
+ function getEffectiveDimensions(provider, dimensions) {
229
+ if (provider === "gemini") {
230
+ return DEFAULT_DIMENSIONS;
231
+ }
232
+ return getPositiveInteger(dimensions ?? DEFAULT_DIMENSIONS, "dimensions");
233
+ }
234
+ function assertBatchSize(metadata, count) {
235
+ if (metadata.batch.maxSize !== void 0 && count > metadata.batch.maxSize) {
236
+ throw providerError(
237
+ metadata.name,
238
+ `batch size ${count} exceeds maximum ${metadata.batch.maxSize}`
239
+ );
240
+ }
241
+ }
242
+ function createEmbeddingBatchResult(metadata, intent, embeddings) {
243
+ return Object.freeze({
244
+ embeddings,
245
+ provider: metadata.name,
246
+ model: metadata.model,
247
+ dimensions: metadata.dimensions,
248
+ intent
85
249
  });
86
- if (!response.ok) {
87
- const error = await response.text();
88
- throw new Error(`OpenAI API error: ${error}`);
250
+ }
251
+ class LocalEmbeddingProvider {
252
+ metadata;
253
+ #timeoutMs;
254
+ constructor(metadata, timeoutMs) {
255
+ this.metadata = metadata;
256
+ this.#timeoutMs = timeoutMs;
257
+ }
258
+ async embed(texts, options = {}) {
259
+ const intent = resolveIntent(options.intent);
260
+ if (texts.length === 0) {
261
+ return createEmbeddingBatchResult(this.metadata, intent, []);
262
+ }
263
+ assertBatchSize(this.metadata, texts.length);
264
+ const vectors = await withTimeout(
265
+ "local",
266
+ "model inference",
267
+ this.#timeoutMs,
268
+ async (signal) => {
269
+ const model = await getLocalEmbeddingModel(this.metadata.model);
270
+ return embedSequentially("local", "model inference", texts, signal, async (text) => {
271
+ const output = await model(text, {
272
+ pooling: "mean",
273
+ normalize: true
274
+ });
275
+ const embedding = Array.from(output.data);
276
+ return padEmbedding(embedding, this.metadata.dimensions);
277
+ });
278
+ },
279
+ options.signal
280
+ );
281
+ return createEmbeddingBatchResult(
282
+ this.metadata,
283
+ intent,
284
+ validateEmbeddingBatch(vectors, texts.length, this.metadata.dimensions, "local")
285
+ );
286
+ }
287
+ }
288
+ class GeminiEmbeddingProvider {
289
+ metadata;
290
+ #apiKey;
291
+ #timeoutMs;
292
+ constructor(metadata, apiKey, timeoutMs) {
293
+ this.metadata = metadata;
294
+ this.#apiKey = apiKey;
295
+ this.#timeoutMs = timeoutMs;
296
+ }
297
+ async embed(texts, options = {}) {
298
+ const intent = resolveIntent(options.intent);
299
+ if (texts.length === 0) {
300
+ return createEmbeddingBatchResult(this.metadata, intent, []);
301
+ }
302
+ assertBatchSize(this.metadata, texts.length);
303
+ const vectors = await withTimeout(
304
+ "gemini",
305
+ "API request",
306
+ this.#timeoutMs,
307
+ async (signal) => {
308
+ const { GoogleGenerativeAI } = await import('@google/generative-ai');
309
+ const genAI = new GoogleGenerativeAI(this.#apiKey);
310
+ const model = genAI.getGenerativeModel({ model: this.metadata.model });
311
+ return embedSequentially("gemini", "API request", texts, signal, async (text, itemSignal) => {
312
+ const result = await model.embedContent(text, { signal: itemSignal });
313
+ const values = result.embedding?.values;
314
+ if (!Array.isArray(values)) {
315
+ throw new Error("Gemini response did not include embedding values");
316
+ }
317
+ return values;
318
+ });
319
+ },
320
+ options.signal,
321
+ [this.#apiKey]
322
+ );
323
+ return createEmbeddingBatchResult(
324
+ this.metadata,
325
+ intent,
326
+ validateEmbeddingBatch(vectors, texts.length, this.metadata.dimensions, "gemini")
327
+ );
89
328
  }
90
- const data = await response.json();
91
- return data.data[0].embedding;
329
+ }
330
+ class OpenAIEmbeddingProvider {
331
+ metadata;
332
+ #apiKey;
333
+ #timeoutMs;
334
+ constructor(metadata, apiKey, timeoutMs) {
335
+ this.metadata = metadata;
336
+ this.#apiKey = apiKey;
337
+ this.#timeoutMs = timeoutMs;
338
+ }
339
+ async embed(texts, options = {}) {
340
+ const intent = resolveIntent(options.intent);
341
+ if (texts.length === 0) {
342
+ return createEmbeddingBatchResult(this.metadata, intent, []);
343
+ }
344
+ assertBatchSize(this.metadata, texts.length);
345
+ const items = await withTimeout(
346
+ "openai",
347
+ "API request",
348
+ this.#timeoutMs,
349
+ async (signal) => {
350
+ const response = await fetch(OPENAI_EMBEDDINGS_URL, {
351
+ method: "POST",
352
+ headers: {
353
+ "Content-Type": "application/json",
354
+ "Authorization": `Bearer ${this.#apiKey}`
355
+ },
356
+ signal,
357
+ body: JSON.stringify({
358
+ input: texts,
359
+ model: this.metadata.model,
360
+ dimensions: this.metadata.dimensions
361
+ })
362
+ });
363
+ if (!response.ok) {
364
+ const requestId = getResponseHeader(response, "x-request-id");
365
+ const context = requestId ? ` status ${response.status}, request ${requestId}` : ` status ${response.status}`;
366
+ throw new Error(context);
367
+ }
368
+ const data = await response.json();
369
+ if (!Array.isArray(data.data)) {
370
+ throw new Error("OpenAI response did not include a data array");
371
+ }
372
+ return data.data.map((item) => {
373
+ const typed = item;
374
+ const embedding = typed.embedding;
375
+ if (!Array.isArray(embedding)) {
376
+ throw new Error("OpenAI response item did not include an embedding array");
377
+ }
378
+ const parsed = { embedding };
379
+ if (hasOwnProperty(typed, "index")) {
380
+ parsed.index = typed.index;
381
+ }
382
+ return parsed;
383
+ });
384
+ },
385
+ options.signal,
386
+ [this.#apiKey]
387
+ );
388
+ return createEmbeddingBatchResult(
389
+ this.metadata,
390
+ intent,
391
+ validateEmbeddingBatch(items, texts.length, this.metadata.dimensions, "openai")
392
+ );
393
+ }
394
+ }
395
+ function createEmbeddingProvider(options = {}) {
396
+ const provider = resolveProviderName(options.provider);
397
+ const metadata = getEmbeddingProviderMetadata(options);
398
+ const timeoutMs = getTimeoutMs(options.timeoutMs);
399
+ switch (provider) {
400
+ case "local":
401
+ return new LocalEmbeddingProvider(metadata, timeoutMs);
402
+ case "gemini": {
403
+ const key = options.apiKey || getEnvironmentVariable("GEMINI_API_KEY");
404
+ if (!key) {
405
+ throw new Error("GEMINI_API_KEY is required for Gemini embeddings");
406
+ }
407
+ return new GeminiEmbeddingProvider(metadata, key, timeoutMs);
408
+ }
409
+ case "openai": {
410
+ const key = options.apiKey || getEnvironmentVariable("OPENAI_API_KEY");
411
+ if (!key) {
412
+ throw new Error("OPENAI_API_KEY is required for OpenAI embeddings");
413
+ }
414
+ return new OpenAIEmbeddingProvider(metadata, key, timeoutMs);
415
+ }
416
+ }
417
+ }
418
+ function getEmbeddingProviderMetadata(options = {}) {
419
+ const provider = resolveProviderName(options.provider);
420
+ return createProviderMetadata(provider, getEffectiveDimensions(provider, options.dimensions));
421
+ }
422
+ async function generateEmbeddings(texts, options = {}) {
423
+ const maxLength = getPositiveInteger(options.maxLength ?? DEFAULT_MAX_LENGTH, "maxLength");
424
+ const intent = resolveIntent(options.intent);
425
+ if (texts.length === 0) {
426
+ return [];
427
+ }
428
+ const provider = createEmbeddingProvider(options);
429
+ const result = await provider.embed(truncateTexts(texts, maxLength), {
430
+ intent,
431
+ ...options.signal ? { signal: options.signal } : {}
432
+ });
433
+ return result.embeddings;
434
+ }
435
+ async function generateEmbedding(text, options = {}) {
436
+ const [embedding] = await generateEmbeddings([text], options);
437
+ if (!embedding) {
438
+ throw new Error("Embedding provider returned no embedding");
439
+ }
440
+ return embedding;
92
441
  }
93
442
  function padEmbedding(embedding, targetDimensions) {
94
443
  if (embedding.length === targetDimensions) {
@@ -209,7 +558,10 @@ async function processFile(file, embeddingOptions) {
209
558
  content: markdown,
210
559
  tags
211
560
  });
212
- const embedding = await generateEmbedding(embeddingText, embeddingOptions);
561
+ const embedding = await generateEmbedding(embeddingText, {
562
+ ...embeddingOptions,
563
+ intent: embeddingOptions.intent ?? "document"
564
+ });
213
565
  return {
214
566
  slug,
215
567
  title,
@@ -278,7 +630,10 @@ async function search(options) {
278
630
  } = options;
279
631
  const quotedTableName = quoteSqlIdentifier(tableName, "tableName");
280
632
  const resultLimit = normalizeSearchLimit(limit);
281
- const queryEmbedding = await generateEmbedding(query, embeddingOptions);
633
+ const queryEmbedding = await generateEmbedding(query, {
634
+ ...embeddingOptions,
635
+ intent: embeddingOptions.intent ?? "query"
636
+ });
282
637
  const results = await client.execute({
283
638
  sql: `
284
639
  SELECT
@@ -380,4 +735,4 @@ async function getFolders(client, tableName = "articles") {
380
735
  return results.rows.map((row) => row.folder);
381
736
  }
382
737
 
383
- export { createTable, generateEmbedding, getAllArticles, getArticleBySlug, getArticlesByFolder, getFolders, indexContent, padEmbedding, prepareTextForEmbedding, search };
738
+ 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.