libsql-search 0.3.0 → 0.5.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/README.md CHANGED
@@ -21,8 +21,9 @@ Use it when you want:
21
21
  - Markdown indexing from local directories with frontmatter via `gray-matter`
22
22
  - libSQL/Turso storage and vector search
23
23
  - Embedding providers: local `Xenova/all-MiniLM-L6-v2`, Cloudflare Workers AI
24
- `@cf/baai/bge-m3`, Google Gemini `text-embedding-004`, and OpenAI
25
- `text-embedding-3-small` / `text-embedding-3-large`
24
+ `@cf/baai/bge-m3`, Mistral `mistral-embed`, Google Gemini
25
+ `gemini-embedding-2`, and OpenAI `text-embedding-3-small` /
26
+ `text-embedding-3-large`
26
27
  - npm distribution plus JSR publishing
27
28
 
28
29
  ## Install
@@ -98,7 +99,8 @@ Important behavior:
98
99
  - Keep dimensions aligned across table creation, indexing, and search queries.
99
100
  - `indexContent()` clears existing rows before rebuilding the index.
100
101
  - `local` is the default offline provider; Cloudflare is the recommended hosted
101
- option and uses 1024 dimensions.
102
+ option. Cloudflare and Mistral use 1024 dimensions. Gemini defaults to 3072
103
+ dimensions and supports 128-3072.
102
104
 
103
105
  ## Core API
104
106
 
package/dist/index.cjs CHANGED
@@ -8,10 +8,16 @@ const DEFAULT_DIMENSIONS = 768;
8
8
  const DEFAULT_MAX_LENGTH = 8e3;
9
9
  const DEFAULT_TIMEOUT_MS = 3e4;
10
10
  const LOCAL_MODEL = "Xenova/all-MiniLM-L6-v2";
11
- const GEMINI_MODEL = "text-embedding-004";
11
+ const GEMINI_MODEL = "gemini-embedding-2";
12
+ const GEMINI_DIMENSIONS = 3072;
13
+ const GEMINI_MIN_DIMENSIONS = 128;
14
+ const GEMINI_MAX_DIMENSIONS = 3072;
12
15
  const OPENAI_SMALL_MODEL = "text-embedding-3-small";
13
16
  const OPENAI_LARGE_MODEL = "text-embedding-3-large";
14
17
  const OPENAI_EMBEDDINGS_URL = "https://api.openai.com/v1/embeddings";
18
+ const MISTRAL_MODEL = "mistral-embed";
19
+ const MISTRAL_EMBEDDINGS_URL = "https://api.mistral.ai/v1/embeddings";
20
+ const MISTRAL_DIMENSIONS = 1024;
15
21
  const CLOUDFLARE_MODEL = "@cf/baai/bge-m3";
16
22
  const CLOUDFLARE_DIMENSIONS = 1024;
17
23
  const localModelCacheByModel = /* @__PURE__ */ new Map();
@@ -53,6 +59,7 @@ function resolveProviderName(provider) {
53
59
  case "local":
54
60
  case "gemini":
55
61
  case "openai":
62
+ case "mistral":
56
63
  case "cloudflare":
57
64
  return provider ?? "local";
58
65
  default:
@@ -81,6 +88,9 @@ function getOptionalTrimmedCredential(value) {
81
88
  function hasOwnProperty(value, property) {
82
89
  return Object.prototype.hasOwnProperty.call(value, property);
83
90
  }
91
+ function isRecord(value) {
92
+ return typeof value === "object" && value !== null;
93
+ }
84
94
  function redactErrorText(value, exactSecrets = []) {
85
95
  let raw = value instanceof Error ? value.message : String(value);
86
96
  for (const secret of exactSecrets) {
@@ -114,6 +124,22 @@ function getSafeResponseHeader(response, name) {
114
124
  function createCloudflareEmbeddingsUrl(accountId) {
115
125
  return `https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(accountId)}/ai/v1/embeddings`;
116
126
  }
127
+ function formatGeminiEmbeddingContent(text, intent) {
128
+ return intent === "query" ? `task: search result | query: ${text}` : `title: none | text: ${text}`;
129
+ }
130
+ function parseGeminiEmbeddingResult(result) {
131
+ if (!Array.isArray(result.embeddings)) {
132
+ throw new Error("Gemini response did not include an embeddings array");
133
+ }
134
+ if (result.embeddings.length !== 1) {
135
+ throw new Error(`Gemini response included ${result.embeddings.length} embedding result(s) for one input`);
136
+ }
137
+ const values = result.embeddings[0]?.values;
138
+ if (!Array.isArray(values)) {
139
+ throw new Error("Gemini response did not include embedding values");
140
+ }
141
+ return values;
142
+ }
117
143
  async function withTimeout(provider, operation, timeoutMs, run, parentSignal, exactSecrets = []) {
118
144
  if (parentSignal?.aborted) {
119
145
  throw providerError(provider, `${operation} was aborted`);
@@ -232,7 +258,7 @@ function createProviderMetadata(provider, dimensions) {
232
258
  return Object.freeze({
233
259
  name: "gemini",
234
260
  model: GEMINI_MODEL,
235
- dimensions: DEFAULT_DIMENSIONS,
261
+ dimensions,
236
262
  batch: Object.freeze({ mode: "sequential" })
237
263
  });
238
264
  case "openai":
@@ -242,6 +268,13 @@ function createProviderMetadata(provider, dimensions) {
242
268
  dimensions,
243
269
  batch: Object.freeze({ mode: "native", maxSize: 2048 })
244
270
  });
271
+ case "mistral":
272
+ return Object.freeze({
273
+ name: "mistral",
274
+ model: MISTRAL_MODEL,
275
+ dimensions: MISTRAL_DIMENSIONS,
276
+ batch: Object.freeze({ mode: "native" })
277
+ });
245
278
  case "cloudflare":
246
279
  return Object.freeze({
247
280
  name: "cloudflare",
@@ -253,7 +286,23 @@ function createProviderMetadata(provider, dimensions) {
253
286
  }
254
287
  function getEffectiveDimensions(provider, dimensions) {
255
288
  if (provider === "gemini") {
256
- return DEFAULT_DIMENSIONS;
289
+ const effectiveDimensions = dimensions ?? GEMINI_DIMENSIONS;
290
+ if (!Number.isInteger(effectiveDimensions) || effectiveDimensions < GEMINI_MIN_DIMENSIONS || effectiveDimensions > GEMINI_MAX_DIMENSIONS) {
291
+ throw providerError(
292
+ "gemini",
293
+ `${GEMINI_MODEL} supports dimensions from ${GEMINI_MIN_DIMENSIONS} to ${GEMINI_MAX_DIMENSIONS}; received dimensions ${String(dimensions)}`
294
+ );
295
+ }
296
+ return effectiveDimensions;
297
+ }
298
+ if (provider === "mistral") {
299
+ if (dimensions !== void 0 && dimensions !== MISTRAL_DIMENSIONS) {
300
+ throw providerError(
301
+ "mistral",
302
+ `${MISTRAL_MODEL} returns ${MISTRAL_DIMENSIONS} dimensions; received dimensions ${dimensions}`
303
+ );
304
+ }
305
+ return MISTRAL_DIMENSIONS;
257
306
  }
258
307
  if (provider === "cloudflare") {
259
308
  if (dimensions !== void 0 && dimensions !== CLOUDFLARE_DIMENSIONS) {
@@ -340,16 +389,18 @@ class GeminiEmbeddingProvider {
340
389
  "API request",
341
390
  this.#timeoutMs,
342
391
  async (signal) => {
343
- const { GoogleGenerativeAI } = await import('@google/generative-ai');
344
- const genAI = new GoogleGenerativeAI(this.#apiKey);
345
- const model = genAI.getGenerativeModel({ model: this.metadata.model });
392
+ const { GoogleGenAI } = await import('@google/genai');
393
+ const client = new GoogleGenAI({ apiKey: this.#apiKey });
346
394
  return embedSequentially("gemini", "API request", texts, signal, async (text, itemSignal) => {
347
- const result = await model.embedContent(text, { signal: itemSignal });
348
- const values = result.embedding?.values;
349
- if (!Array.isArray(values)) {
350
- throw new Error("Gemini response did not include embedding values");
351
- }
352
- return values;
395
+ const result = await client.models.embedContent({
396
+ model: this.metadata.model,
397
+ contents: formatGeminiEmbeddingContent(text, intent),
398
+ config: {
399
+ outputDimensionality: this.metadata.dimensions,
400
+ abortSignal: itemSignal
401
+ }
402
+ });
403
+ return parseGeminiEmbeddingResult(result);
353
404
  });
354
405
  },
355
406
  options.signal,
@@ -362,6 +413,52 @@ class GeminiEmbeddingProvider {
362
413
  );
363
414
  }
364
415
  }
416
+ async function requestOpenAICompatibleEmbeddings(request) {
417
+ const body = {
418
+ input: request.texts,
419
+ model: request.model
420
+ };
421
+ if (request.dimensions !== void 0) {
422
+ body.dimensions = request.dimensions;
423
+ }
424
+ if (request.encodingFormat !== void 0) {
425
+ body.encoding_format = request.encodingFormat;
426
+ }
427
+ const response = await fetch(request.endpoint, {
428
+ method: "POST",
429
+ headers: {
430
+ "Content-Type": "application/json",
431
+ "Authorization": `Bearer ${request.apiKey}`
432
+ },
433
+ signal: request.signal,
434
+ body: JSON.stringify(body)
435
+ });
436
+ if (!response.ok) {
437
+ const requestId = getSafeResponseHeader(response, "x-request-id");
438
+ const context = requestId ? ` status ${response.status}, request ${requestId}` : ` status ${response.status}`;
439
+ throw new Error(context);
440
+ }
441
+ const data = await response.json();
442
+ if (!Array.isArray(data.data)) {
443
+ throw new Error(`${request.responseLabel} response did not include a data array`);
444
+ }
445
+ return data.data.map((item) => {
446
+ if (!isRecord(item)) {
447
+ throw new Error(`${request.responseLabel} response item was not an object`);
448
+ }
449
+ const embedding = item.embedding;
450
+ if (!Array.isArray(embedding)) {
451
+ throw new Error(`${request.responseLabel} response item did not include an embedding array`);
452
+ }
453
+ const parsed = { embedding };
454
+ if (hasOwnProperty(item, "index")) {
455
+ parsed.index = item.index;
456
+ } else if (request.requireIndex) {
457
+ throw new Error(`${request.responseLabel} response item did not include an index`);
458
+ }
459
+ return parsed;
460
+ });
461
+ }
365
462
  class OpenAIEmbeddingProvider {
366
463
  metadata;
367
464
  #apiKey;
@@ -381,42 +478,15 @@ class OpenAIEmbeddingProvider {
381
478
  "openai",
382
479
  "API request",
383
480
  this.#timeoutMs,
384
- async (signal) => {
385
- const response = await fetch(OPENAI_EMBEDDINGS_URL, {
386
- method: "POST",
387
- headers: {
388
- "Content-Type": "application/json",
389
- "Authorization": `Bearer ${this.#apiKey}`
390
- },
391
- signal,
392
- body: JSON.stringify({
393
- input: texts,
394
- model: this.metadata.model,
395
- dimensions: this.metadata.dimensions
396
- })
397
- });
398
- if (!response.ok) {
399
- const requestId = getResponseHeader(response, "x-request-id");
400
- const context = requestId ? ` status ${response.status}, request ${requestId}` : ` status ${response.status}`;
401
- throw new Error(context);
402
- }
403
- const data = await response.json();
404
- if (!Array.isArray(data.data)) {
405
- throw new Error("OpenAI response did not include a data array");
406
- }
407
- return data.data.map((item) => {
408
- const typed = item;
409
- const embedding = typed.embedding;
410
- if (!Array.isArray(embedding)) {
411
- throw new Error("OpenAI response item did not include an embedding array");
412
- }
413
- const parsed = { embedding };
414
- if (hasOwnProperty(typed, "index")) {
415
- parsed.index = typed.index;
416
- }
417
- return parsed;
418
- });
419
- },
481
+ async (signal) => requestOpenAICompatibleEmbeddings({
482
+ responseLabel: "OpenAI",
483
+ endpoint: OPENAI_EMBEDDINGS_URL,
484
+ apiKey: this.#apiKey,
485
+ model: this.metadata.model,
486
+ texts,
487
+ signal,
488
+ dimensions: this.metadata.dimensions
489
+ }),
420
490
  options.signal,
421
491
  [this.#apiKey]
422
492
  );
@@ -427,6 +497,45 @@ class OpenAIEmbeddingProvider {
427
497
  );
428
498
  }
429
499
  }
500
+ class MistralEmbeddingProvider {
501
+ metadata;
502
+ #apiKey;
503
+ #timeoutMs;
504
+ constructor(metadata, apiKey, timeoutMs) {
505
+ this.metadata = metadata;
506
+ this.#apiKey = apiKey;
507
+ this.#timeoutMs = timeoutMs;
508
+ }
509
+ async embed(texts, options = {}) {
510
+ const intent = resolveIntent(options.intent);
511
+ if (texts.length === 0) {
512
+ return createEmbeddingBatchResult(this.metadata, intent, []);
513
+ }
514
+ assertBatchSize(this.metadata, texts.length);
515
+ const items = await withTimeout(
516
+ "mistral",
517
+ "API request",
518
+ this.#timeoutMs,
519
+ async (signal) => requestOpenAICompatibleEmbeddings({
520
+ responseLabel: "Mistral",
521
+ endpoint: MISTRAL_EMBEDDINGS_URL,
522
+ apiKey: this.#apiKey,
523
+ model: this.metadata.model,
524
+ texts,
525
+ signal,
526
+ encodingFormat: "float",
527
+ requireIndex: true
528
+ }),
529
+ options.signal,
530
+ [this.#apiKey]
531
+ );
532
+ return createEmbeddingBatchResult(
533
+ this.metadata,
534
+ intent,
535
+ validateEmbeddingBatch(items, texts.length, this.metadata.dimensions, "mistral")
536
+ );
537
+ }
538
+ }
430
539
  class CloudflareEmbeddingProvider {
431
540
  metadata;
432
541
  #accountId;
@@ -502,7 +611,9 @@ function createEmbeddingProvider(options = {}) {
502
611
  case "local":
503
612
  return new LocalEmbeddingProvider(metadata, timeoutMs);
504
613
  case "gemini": {
505
- const key = options.apiKey || getEnvironmentVariable("GEMINI_API_KEY");
614
+ const key = getOptionalTrimmedCredential(
615
+ options.apiKey ?? getEnvironmentVariable("GEMINI_API_KEY")
616
+ );
506
617
  if (!key) {
507
618
  throw new Error("GEMINI_API_KEY is required for Gemini embeddings");
508
619
  }
@@ -515,6 +626,15 @@ function createEmbeddingProvider(options = {}) {
515
626
  }
516
627
  return new OpenAIEmbeddingProvider(metadata, key, timeoutMs);
517
628
  }
629
+ case "mistral": {
630
+ const key = getOptionalTrimmedCredential(
631
+ options.apiKey ?? getEnvironmentVariable("MISTRAL_API_KEY")
632
+ );
633
+ if (!key) {
634
+ throw new Error("MISTRAL_API_KEY is required for Mistral embeddings");
635
+ }
636
+ return new MistralEmbeddingProvider(metadata, key, timeoutMs);
637
+ }
518
638
  case "cloudflare": {
519
639
  const accountId = getOptionalTrimmedCredential(
520
640
  options.accountId ?? getEnvironmentVariable("CLOUDFLARE_ACCOUNT_ID")
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Client } from '@libsql/client';
2
2
 
3
- type EmbeddingProvider = 'local' | 'gemini' | 'openai' | 'cloudflare';
3
+ type EmbeddingProvider = 'local' | 'gemini' | 'openai' | 'mistral' | 'cloudflare';
4
4
  type EmbeddingIntent = 'document' | 'query';
5
5
  type EmbeddingBatchMode = 'native' | 'sequential';
6
6
  interface EmbeddingBatchBehavior {
package/dist/index.esm.js CHANGED
@@ -6,10 +6,16 @@ const DEFAULT_DIMENSIONS = 768;
6
6
  const DEFAULT_MAX_LENGTH = 8e3;
7
7
  const DEFAULT_TIMEOUT_MS = 3e4;
8
8
  const LOCAL_MODEL = "Xenova/all-MiniLM-L6-v2";
9
- const GEMINI_MODEL = "text-embedding-004";
9
+ const GEMINI_MODEL = "gemini-embedding-2";
10
+ const GEMINI_DIMENSIONS = 3072;
11
+ const GEMINI_MIN_DIMENSIONS = 128;
12
+ const GEMINI_MAX_DIMENSIONS = 3072;
10
13
  const OPENAI_SMALL_MODEL = "text-embedding-3-small";
11
14
  const OPENAI_LARGE_MODEL = "text-embedding-3-large";
12
15
  const OPENAI_EMBEDDINGS_URL = "https://api.openai.com/v1/embeddings";
16
+ const MISTRAL_MODEL = "mistral-embed";
17
+ const MISTRAL_EMBEDDINGS_URL = "https://api.mistral.ai/v1/embeddings";
18
+ const MISTRAL_DIMENSIONS = 1024;
13
19
  const CLOUDFLARE_MODEL = "@cf/baai/bge-m3";
14
20
  const CLOUDFLARE_DIMENSIONS = 1024;
15
21
  const localModelCacheByModel = /* @__PURE__ */ new Map();
@@ -51,6 +57,7 @@ function resolveProviderName(provider) {
51
57
  case "local":
52
58
  case "gemini":
53
59
  case "openai":
60
+ case "mistral":
54
61
  case "cloudflare":
55
62
  return provider ?? "local";
56
63
  default:
@@ -79,6 +86,9 @@ function getOptionalTrimmedCredential(value) {
79
86
  function hasOwnProperty(value, property) {
80
87
  return Object.prototype.hasOwnProperty.call(value, property);
81
88
  }
89
+ function isRecord(value) {
90
+ return typeof value === "object" && value !== null;
91
+ }
82
92
  function redactErrorText(value, exactSecrets = []) {
83
93
  let raw = value instanceof Error ? value.message : String(value);
84
94
  for (const secret of exactSecrets) {
@@ -112,6 +122,22 @@ function getSafeResponseHeader(response, name) {
112
122
  function createCloudflareEmbeddingsUrl(accountId) {
113
123
  return `https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(accountId)}/ai/v1/embeddings`;
114
124
  }
125
+ function formatGeminiEmbeddingContent(text, intent) {
126
+ return intent === "query" ? `task: search result | query: ${text}` : `title: none | text: ${text}`;
127
+ }
128
+ function parseGeminiEmbeddingResult(result) {
129
+ if (!Array.isArray(result.embeddings)) {
130
+ throw new Error("Gemini response did not include an embeddings array");
131
+ }
132
+ if (result.embeddings.length !== 1) {
133
+ throw new Error(`Gemini response included ${result.embeddings.length} embedding result(s) for one input`);
134
+ }
135
+ const values = result.embeddings[0]?.values;
136
+ if (!Array.isArray(values)) {
137
+ throw new Error("Gemini response did not include embedding values");
138
+ }
139
+ return values;
140
+ }
115
141
  async function withTimeout(provider, operation, timeoutMs, run, parentSignal, exactSecrets = []) {
116
142
  if (parentSignal?.aborted) {
117
143
  throw providerError(provider, `${operation} was aborted`);
@@ -230,7 +256,7 @@ function createProviderMetadata(provider, dimensions) {
230
256
  return Object.freeze({
231
257
  name: "gemini",
232
258
  model: GEMINI_MODEL,
233
- dimensions: DEFAULT_DIMENSIONS,
259
+ dimensions,
234
260
  batch: Object.freeze({ mode: "sequential" })
235
261
  });
236
262
  case "openai":
@@ -240,6 +266,13 @@ function createProviderMetadata(provider, dimensions) {
240
266
  dimensions,
241
267
  batch: Object.freeze({ mode: "native", maxSize: 2048 })
242
268
  });
269
+ case "mistral":
270
+ return Object.freeze({
271
+ name: "mistral",
272
+ model: MISTRAL_MODEL,
273
+ dimensions: MISTRAL_DIMENSIONS,
274
+ batch: Object.freeze({ mode: "native" })
275
+ });
243
276
  case "cloudflare":
244
277
  return Object.freeze({
245
278
  name: "cloudflare",
@@ -251,7 +284,23 @@ function createProviderMetadata(provider, dimensions) {
251
284
  }
252
285
  function getEffectiveDimensions(provider, dimensions) {
253
286
  if (provider === "gemini") {
254
- return DEFAULT_DIMENSIONS;
287
+ const effectiveDimensions = dimensions ?? GEMINI_DIMENSIONS;
288
+ if (!Number.isInteger(effectiveDimensions) || effectiveDimensions < GEMINI_MIN_DIMENSIONS || effectiveDimensions > GEMINI_MAX_DIMENSIONS) {
289
+ throw providerError(
290
+ "gemini",
291
+ `${GEMINI_MODEL} supports dimensions from ${GEMINI_MIN_DIMENSIONS} to ${GEMINI_MAX_DIMENSIONS}; received dimensions ${String(dimensions)}`
292
+ );
293
+ }
294
+ return effectiveDimensions;
295
+ }
296
+ if (provider === "mistral") {
297
+ if (dimensions !== void 0 && dimensions !== MISTRAL_DIMENSIONS) {
298
+ throw providerError(
299
+ "mistral",
300
+ `${MISTRAL_MODEL} returns ${MISTRAL_DIMENSIONS} dimensions; received dimensions ${dimensions}`
301
+ );
302
+ }
303
+ return MISTRAL_DIMENSIONS;
255
304
  }
256
305
  if (provider === "cloudflare") {
257
306
  if (dimensions !== void 0 && dimensions !== CLOUDFLARE_DIMENSIONS) {
@@ -338,16 +387,18 @@ class GeminiEmbeddingProvider {
338
387
  "API request",
339
388
  this.#timeoutMs,
340
389
  async (signal) => {
341
- const { GoogleGenerativeAI } = await import('@google/generative-ai');
342
- const genAI = new GoogleGenerativeAI(this.#apiKey);
343
- const model = genAI.getGenerativeModel({ model: this.metadata.model });
390
+ const { GoogleGenAI } = await import('@google/genai');
391
+ const client = new GoogleGenAI({ apiKey: this.#apiKey });
344
392
  return embedSequentially("gemini", "API request", texts, signal, async (text, itemSignal) => {
345
- const result = await model.embedContent(text, { signal: itemSignal });
346
- const values = result.embedding?.values;
347
- if (!Array.isArray(values)) {
348
- throw new Error("Gemini response did not include embedding values");
349
- }
350
- return values;
393
+ const result = await client.models.embedContent({
394
+ model: this.metadata.model,
395
+ contents: formatGeminiEmbeddingContent(text, intent),
396
+ config: {
397
+ outputDimensionality: this.metadata.dimensions,
398
+ abortSignal: itemSignal
399
+ }
400
+ });
401
+ return parseGeminiEmbeddingResult(result);
351
402
  });
352
403
  },
353
404
  options.signal,
@@ -360,6 +411,52 @@ class GeminiEmbeddingProvider {
360
411
  );
361
412
  }
362
413
  }
414
+ async function requestOpenAICompatibleEmbeddings(request) {
415
+ const body = {
416
+ input: request.texts,
417
+ model: request.model
418
+ };
419
+ if (request.dimensions !== void 0) {
420
+ body.dimensions = request.dimensions;
421
+ }
422
+ if (request.encodingFormat !== void 0) {
423
+ body.encoding_format = request.encodingFormat;
424
+ }
425
+ const response = await fetch(request.endpoint, {
426
+ method: "POST",
427
+ headers: {
428
+ "Content-Type": "application/json",
429
+ "Authorization": `Bearer ${request.apiKey}`
430
+ },
431
+ signal: request.signal,
432
+ body: JSON.stringify(body)
433
+ });
434
+ if (!response.ok) {
435
+ const requestId = getSafeResponseHeader(response, "x-request-id");
436
+ const context = requestId ? ` status ${response.status}, request ${requestId}` : ` status ${response.status}`;
437
+ throw new Error(context);
438
+ }
439
+ const data = await response.json();
440
+ if (!Array.isArray(data.data)) {
441
+ throw new Error(`${request.responseLabel} response did not include a data array`);
442
+ }
443
+ return data.data.map((item) => {
444
+ if (!isRecord(item)) {
445
+ throw new Error(`${request.responseLabel} response item was not an object`);
446
+ }
447
+ const embedding = item.embedding;
448
+ if (!Array.isArray(embedding)) {
449
+ throw new Error(`${request.responseLabel} response item did not include an embedding array`);
450
+ }
451
+ const parsed = { embedding };
452
+ if (hasOwnProperty(item, "index")) {
453
+ parsed.index = item.index;
454
+ } else if (request.requireIndex) {
455
+ throw new Error(`${request.responseLabel} response item did not include an index`);
456
+ }
457
+ return parsed;
458
+ });
459
+ }
363
460
  class OpenAIEmbeddingProvider {
364
461
  metadata;
365
462
  #apiKey;
@@ -379,42 +476,15 @@ class OpenAIEmbeddingProvider {
379
476
  "openai",
380
477
  "API request",
381
478
  this.#timeoutMs,
382
- async (signal) => {
383
- const response = await fetch(OPENAI_EMBEDDINGS_URL, {
384
- method: "POST",
385
- headers: {
386
- "Content-Type": "application/json",
387
- "Authorization": `Bearer ${this.#apiKey}`
388
- },
389
- signal,
390
- body: JSON.stringify({
391
- input: texts,
392
- model: this.metadata.model,
393
- dimensions: this.metadata.dimensions
394
- })
395
- });
396
- if (!response.ok) {
397
- const requestId = getResponseHeader(response, "x-request-id");
398
- const context = requestId ? ` status ${response.status}, request ${requestId}` : ` status ${response.status}`;
399
- throw new Error(context);
400
- }
401
- const data = await response.json();
402
- if (!Array.isArray(data.data)) {
403
- throw new Error("OpenAI response did not include a data array");
404
- }
405
- return data.data.map((item) => {
406
- const typed = item;
407
- const embedding = typed.embedding;
408
- if (!Array.isArray(embedding)) {
409
- throw new Error("OpenAI response item did not include an embedding array");
410
- }
411
- const parsed = { embedding };
412
- if (hasOwnProperty(typed, "index")) {
413
- parsed.index = typed.index;
414
- }
415
- return parsed;
416
- });
417
- },
479
+ async (signal) => requestOpenAICompatibleEmbeddings({
480
+ responseLabel: "OpenAI",
481
+ endpoint: OPENAI_EMBEDDINGS_URL,
482
+ apiKey: this.#apiKey,
483
+ model: this.metadata.model,
484
+ texts,
485
+ signal,
486
+ dimensions: this.metadata.dimensions
487
+ }),
418
488
  options.signal,
419
489
  [this.#apiKey]
420
490
  );
@@ -425,6 +495,45 @@ class OpenAIEmbeddingProvider {
425
495
  );
426
496
  }
427
497
  }
498
+ class MistralEmbeddingProvider {
499
+ metadata;
500
+ #apiKey;
501
+ #timeoutMs;
502
+ constructor(metadata, apiKey, timeoutMs) {
503
+ this.metadata = metadata;
504
+ this.#apiKey = apiKey;
505
+ this.#timeoutMs = timeoutMs;
506
+ }
507
+ async embed(texts, options = {}) {
508
+ const intent = resolveIntent(options.intent);
509
+ if (texts.length === 0) {
510
+ return createEmbeddingBatchResult(this.metadata, intent, []);
511
+ }
512
+ assertBatchSize(this.metadata, texts.length);
513
+ const items = await withTimeout(
514
+ "mistral",
515
+ "API request",
516
+ this.#timeoutMs,
517
+ async (signal) => requestOpenAICompatibleEmbeddings({
518
+ responseLabel: "Mistral",
519
+ endpoint: MISTRAL_EMBEDDINGS_URL,
520
+ apiKey: this.#apiKey,
521
+ model: this.metadata.model,
522
+ texts,
523
+ signal,
524
+ encodingFormat: "float",
525
+ requireIndex: true
526
+ }),
527
+ options.signal,
528
+ [this.#apiKey]
529
+ );
530
+ return createEmbeddingBatchResult(
531
+ this.metadata,
532
+ intent,
533
+ validateEmbeddingBatch(items, texts.length, this.metadata.dimensions, "mistral")
534
+ );
535
+ }
536
+ }
428
537
  class CloudflareEmbeddingProvider {
429
538
  metadata;
430
539
  #accountId;
@@ -500,7 +609,9 @@ function createEmbeddingProvider(options = {}) {
500
609
  case "local":
501
610
  return new LocalEmbeddingProvider(metadata, timeoutMs);
502
611
  case "gemini": {
503
- const key = options.apiKey || getEnvironmentVariable("GEMINI_API_KEY");
612
+ const key = getOptionalTrimmedCredential(
613
+ options.apiKey ?? getEnvironmentVariable("GEMINI_API_KEY")
614
+ );
504
615
  if (!key) {
505
616
  throw new Error("GEMINI_API_KEY is required for Gemini embeddings");
506
617
  }
@@ -513,6 +624,15 @@ function createEmbeddingProvider(options = {}) {
513
624
  }
514
625
  return new OpenAIEmbeddingProvider(metadata, key, timeoutMs);
515
626
  }
627
+ case "mistral": {
628
+ const key = getOptionalTrimmedCredential(
629
+ options.apiKey ?? getEnvironmentVariable("MISTRAL_API_KEY")
630
+ );
631
+ if (!key) {
632
+ throw new Error("MISTRAL_API_KEY is required for Mistral embeddings");
633
+ }
634
+ return new MistralEmbeddingProvider(metadata, key, timeoutMs);
635
+ }
516
636
  case "cloudflare": {
517
637
  const accountId = getOptionalTrimmedCredential(
518
638
  options.accountId ?? getEnvironmentVariable("CLOUDFLARE_ACCOUNT_ID")
package/docs/API.md CHANGED
@@ -210,8 +210,11 @@ Provider metadata includes:
210
210
  - `batch.maxSize`, when the provider has a hard maximum
211
211
 
212
212
  Hosted provider clients are scoped to their options. The library does not reuse
213
- a Cloudflare, Gemini, or OpenAI client created with different credentials or
214
- configuration.
213
+ a Cloudflare, Mistral, Gemini, or OpenAI client created with different
214
+ credentials or configuration.
215
+
216
+ Gemini uses `gemini-embedding-2`. Its default is 3072 dimensions, and explicit
217
+ Gemini dimensions must be an integer from 128 through 3072.
215
218
 
216
219
  ### `getEmbeddingProviderMetadata(options?)`
217
220
 
@@ -239,7 +242,7 @@ Provider clients return:
239
242
  ```ts
240
243
  interface EmbeddingBatchResult {
241
244
  embeddings: number[][];
242
- provider: "local" | "cloudflare" | "gemini" | "openai";
245
+ provider: "local" | "cloudflare" | "mistral" | "gemini" | "openai";
243
246
  model: string;
244
247
  dimensions: number;
245
248
  intent: "document" | "query";
@@ -255,7 +258,7 @@ cardinality, dimensions, finite numeric values, and indexed batch ordering.
255
258
 
256
259
  ```ts
257
260
  interface EmbeddingOptions {
258
- provider?: "local" | "cloudflare" | "gemini" | "openai";
261
+ provider?: "local" | "cloudflare" | "mistral" | "gemini" | "openai";
259
262
  apiKey?: string;
260
263
  accountId?: string;
261
264
  apiToken?: string;
@@ -267,9 +270,10 @@ interface EmbeddingOptions {
267
270
  }
268
271
  ```
269
272
 
270
- `apiKey` is used by Gemini and OpenAI. Cloudflare uses `accountId` and
271
- `apiToken`, which fall back to `CLOUDFLARE_ACCOUNT_ID` and
272
- `CLOUDFLARE_API_TOKEN`.
273
+ `apiKey` is used by Mistral, Gemini, and OpenAI. It falls back to
274
+ `MISTRAL_API_KEY`, `GEMINI_API_KEY`, or `OPENAI_API_KEY` for those providers.
275
+ Cloudflare uses `accountId` and `apiToken`, which fall back to
276
+ `CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_API_TOKEN`.
273
277
 
274
278
  ### `padEmbedding(embedding, targetDimensions)`
275
279
 
package/docs/INDEXING.md CHANGED
@@ -33,6 +33,14 @@ await indexContent({
33
33
  That keeps the implementation simple, but it also means a failed rebuild can
34
34
  leave the index partially repopulated.
35
35
 
36
+ Changing an embedding provider or dimension count requires a full re-embed.
37
+ For Gemini specifically, indexes created with the retired `text-embedding-004`
38
+ model must be rebuilt for `gemini-embedding-2` even when staying at 768
39
+ dimensions, because the model and query/document formatting both changed. If
40
+ you adopt Gemini's 3072-dimensional default, recreate the vector table or build
41
+ into a separate table first; clearing rows with `indexContent()` does not change
42
+ the table's `F32_BLOB` width.
43
+
36
44
  ## Quality Guidelines
37
45
 
38
46
  - include descriptive frontmatter titles
@@ -128,8 +128,19 @@ const client = createClient({
128
128
  });
129
129
 
130
130
  const embeddingProvider =
131
- process.env.EMBEDDING_PROVIDER as "local" | "cloudflare" | "gemini" | "openai" | undefined;
132
- const embeddingDimensions = embeddingProvider === "cloudflare" ? 1024 : 768;
131
+ process.env.EMBEDDING_PROVIDER as
132
+ | "local"
133
+ | "cloudflare"
134
+ | "mistral"
135
+ | "gemini"
136
+ | "openai"
137
+ | undefined;
138
+ const embeddingDimensions =
139
+ embeddingProvider === "cloudflare" || embeddingProvider === "mistral"
140
+ ? 1024
141
+ : embeddingProvider === "gemini"
142
+ ? 3072
143
+ : 768;
133
144
 
134
145
  await createTable(client, "articles", embeddingDimensions);
135
146
 
package/docs/PROVIDERS.md CHANGED
@@ -1,9 +1,10 @@
1
1
  # Embedding Providers
2
2
 
3
- `libsql-search` currently supports four embedding providers:
3
+ `libsql-search` currently supports five embedding providers:
4
4
 
5
5
  - `local`
6
6
  - `cloudflare`
7
+ - `mistral`
7
8
  - `gemini`
8
9
  - `openai`
9
10
 
@@ -15,7 +16,7 @@ query time.
15
16
 
16
17
  ```ts
17
18
  interface EmbeddingOptions {
18
- provider?: "local" | "cloudflare" | "gemini" | "openai";
19
+ provider?: "local" | "cloudflare" | "mistral" | "gemini" | "openai";
19
20
  apiKey?: string;
20
21
  accountId?: string;
21
22
  apiToken?: string;
@@ -28,13 +29,14 @@ interface EmbeddingOptions {
28
29
  ```
29
30
 
30
31
  - `provider` defaults to `"local"`
31
- - `dimensions` defaults to `768`
32
+ - `dimensions` defaults to `768` for the library's default local provider.
33
+ Provider-specific defaults can differ; Gemini defaults to `3072`.
32
34
  - `maxLength` defaults to `8000`
33
35
  - `intent` can be `"document"` or `"query"`; indexing defaults to
34
36
  `"document"` and search defaults to `"query"` unless explicitly set
35
37
  - `timeoutMs` defaults to `30000`
36
- - `apiKey` is used by Gemini and OpenAI and falls back to `GEMINI_API_KEY` or
37
- `OPENAI_API_KEY`
38
+ - `apiKey` is used by Mistral, Gemini, and OpenAI and falls back to
39
+ `MISTRAL_API_KEY`, `GEMINI_API_KEY`, or `OPENAI_API_KEY`
38
40
  - `accountId` and `apiToken` are used by Cloudflare and fall back to
39
41
  `CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_API_TOKEN`
40
42
 
@@ -44,7 +46,7 @@ Each provider exposes immutable metadata:
44
46
 
45
47
  ```ts
46
48
  interface EmbeddingProviderMetadata {
47
- name: "local" | "cloudflare" | "gemini" | "openai";
49
+ name: "local" | "cloudflare" | "mistral" | "gemini" | "openai";
48
50
  model: string;
49
51
  dimensions: number;
50
52
  batch: {
@@ -83,9 +85,9 @@ Lower-level provider clients return an `EmbeddingBatchResult` with the validated
83
85
  vectors plus provider, model, dimensions, and intent. The compatibility helpers
84
86
  `generateEmbedding()` and `generateEmbeddings()` return only arrays.
85
87
 
86
- Cloudflare, Gemini, and OpenAI clients are scoped to their current options. They
87
- are not cached globally across different credentials or configurations. The
88
- local Xenova model can be cached by model name.
88
+ Cloudflare, Mistral, Gemini, and OpenAI clients are scoped to their current
89
+ options. They are not cached globally across different credentials or
90
+ configurations. The local Xenova model can be cached by model name.
89
91
 
90
92
  Hosted provider failures are reported with bounded provider/status/request-id
91
93
  context and without raw upstream bodies, credentials, Authorization headers, or
@@ -144,26 +146,60 @@ Behavior:
144
146
  - Cloudflare does not accept custom dimensions in this provider; use
145
147
  `createTable(client, "articles", 1024)` for Cloudflare-backed indexes
146
148
 
149
+ ## Mistral
150
+
151
+ Provider value: `mistral`
152
+
153
+ Mistral uses the hosted `mistral-embed` model through
154
+ `https://api.mistral.ai/v1/embeddings`.
155
+
156
+ ```ts
157
+ embeddingOptions: {
158
+ provider: "mistral",
159
+ apiKey: process.env.MISTRAL_API_KEY,
160
+ }
161
+ ```
162
+
163
+ Behavior:
164
+
165
+ - if `apiKey` is omitted, the library reads `MISTRAL_API_KEY`
166
+ - blank Mistral credentials are treated as missing
167
+ - `mistral-embed` returns 1024 dimensions
168
+ - metadata reports `mistral-embed` and 1024 dimensions without requiring
169
+ credentials
170
+ - batch metadata is `{ mode: "native" }`
171
+ - request bodies send `encoding_format: "float"`
172
+ - response items are reordered by provider-supplied index before being returned
173
+ - Mistral does not accept custom dimensions in this provider; use
174
+ `createTable(client, "articles", 1024)` for Mistral-backed indexes
175
+
147
176
  ## Gemini
148
177
 
149
178
  Provider value: `gemini`
150
179
 
151
- Gemini uses Google `text-embedding-004`.
180
+ Gemini uses Google `gemini-embedding-2` through `@google/genai`.
152
181
 
153
182
  ```ts
154
183
  embeddingOptions: {
155
184
  provider: "gemini",
156
185
  apiKey: process.env.GEMINI_API_KEY,
186
+ dimensions: 3072,
157
187
  }
158
188
  ```
159
189
 
160
190
  Behavior:
161
191
 
162
192
  - if `apiKey` is omitted, the library reads `GEMINI_API_KEY`
163
- - Gemini returns 768 dimensions natively
164
- - metadata reports `text-embedding-004` and 768 dimensions
193
+ - blank Gemini credentials are treated as missing
194
+ - Gemini defaults to 3072 dimensions
195
+ - explicit Gemini dimensions must be integers from 128 through 3072
196
+ - 768, 1536, and 3072 are recommended practical sizes
197
+ - metadata reports `gemini-embedding-2` and the effective dimensions
165
198
  - batch metadata is `{ mode: "sequential" }`
166
- - the current implementation does not expose model selection
199
+ - the library sends one SDK request per input and verifies one vector per input
200
+ - document inputs are formatted as `title: none | text: ...`
201
+ - query inputs are formatted as `task: search result | query: ...`
202
+ - the current implementation does not expose custom model selection
167
203
 
168
204
  ## OpenAI
169
205
 
@@ -193,10 +229,19 @@ Behavior:
193
229
 
194
230
  - `local` defaults to `768`
195
231
  - `cloudflare` is fixed at `1024`
232
+ - `mistral` is fixed at `1024`
196
233
  - local embeddings are padded from 384 to your target size
197
- - Gemini stays at 768
234
+ - Gemini defaults to `3072` and accepts explicit dimensions from `128` through
235
+ `3072`; use `768`, `1536`, or `3072` unless you have a specific reason
198
236
  - OpenAI can be used at 1536 or 3072, or another supported OpenAI dimension
199
237
  value you explicitly set
200
238
 
201
239
  If you switch provider or dimensions for an existing table, recreate the table
202
240
  or rebuild the index into a separate table so stored vectors stay consistent.
241
+
242
+ Existing Gemini indexes created with `text-embedding-004` must be fully
243
+ re-embedded for `gemini-embedding-2`, even if you keep `dimensions: 768`,
244
+ because both the model and query/document input formatting changed. If you move
245
+ to the new 3072-dimensional default, create a new table or recreate the vector
246
+ table first; `indexContent()` clears rows but does not change the `F32_BLOB`
247
+ width. A separate table is safer because rebuilds are not transactional.
package/docs/README.md CHANGED
@@ -3,8 +3,8 @@
3
3
  This directory holds the longer-form reference material for `libsql-search`.
4
4
  Start with the page that matches the job you are doing:
5
5
 
6
- - [Provider guide](./PROVIDERS.md): local, Gemini, and OpenAI embedding options,
7
- dimensions, and API key behavior
6
+ - [Provider guide](./PROVIDERS.md): local, Cloudflare, Mistral, Gemini, and
7
+ OpenAI embedding options, dimensions, and API key behavior
8
8
  - [API reference](./API.md): exported functions, option shapes, and result data
9
9
  - [Integration examples](./INTEGRATIONS.md): Astro and Next.js server-side usage
10
10
  - [Indexing and operations](./INDEXING.md): content layout, rebuild scripts,
@@ -9,4 +9,9 @@ Common operational checks:
9
9
  - verify you called `createTable()` before indexing or searching
10
10
  - verify the table dimension matches the embedding dimension in your code
11
11
  - verify the same provider is used for indexing and querying
12
- - verify hosted providers have `GEMINI_API_KEY` or `OPENAI_API_KEY` available
12
+ - verify hosted providers have `CLOUDFLARE_ACCOUNT_ID` and
13
+ `CLOUDFLARE_API_TOKEN`, `MISTRAL_API_KEY`, `GEMINI_API_KEY`, or
14
+ `OPENAI_API_KEY` available as required by the selected provider
15
+ - after upgrading an existing Gemini index, fully re-embed with
16
+ `gemini-embedding-2`; for 3072-dimensional Gemini indexes, recreate the table
17
+ or use a new table name before rebuilding
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libsql-search",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Semantic search for static sites using libSQL/Turso with multi-provider embeddings",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@10.34.5",
@@ -66,9 +66,6 @@
66
66
  "@xenova/transformers": "^2.17.2",
67
67
  "gray-matter": "^4.0.3"
68
68
  },
69
- "optionalDependencies": {
70
- "@google/generative-ai": "^0.24.1"
71
- },
72
69
  "devDependencies": {
73
70
  "@libsql/client": "^0.15.15",
74
71
  "@rollup/plugin-commonjs": "^29.0.3",
@@ -82,5 +79,8 @@
82
79
  "tslib": "^2.8.1",
83
80
  "typescript": "^5.9.3",
84
81
  "vitest": "^4.1.11"
82
+ },
83
+ "optionalDependencies": {
84
+ "@google/genai": "2.18.0"
85
85
  }
86
86
  }