libsql-search 0.5.0 → 0.7.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.esm.js CHANGED
@@ -2,7 +2,9 @@ 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 DEFAULT_DIMENSIONS = 768;
5
+ const OPENAI_DEFAULT_DIMENSIONS = 768;
6
+ const OPENAI_COMPATIBLE_DEFAULT_BATCH_SIZE = 32;
7
+ const LOCAL_DIMENSIONS = 384;
6
8
  const DEFAULT_MAX_LENGTH = 8e3;
7
9
  const DEFAULT_TIMEOUT_MS = 3e4;
8
10
  const LOCAL_MODEL = "Xenova/all-MiniLM-L6-v2";
@@ -31,17 +33,66 @@ function getEnvironmentVariable(name) {
31
33
  return void 0;
32
34
  }
33
35
  }
34
- async function getLocalEmbeddingModel(modelName) {
36
+ function deletePendingLocalModelCache(modelName, entry) {
37
+ if (!entry.settled && entry.waiters === 0 && localModelCacheByModel.get(modelName) === entry) {
38
+ localModelCacheByModel.delete(modelName);
39
+ }
40
+ }
41
+ async function getLocalEmbeddingModel(modelName, signal) {
35
42
  const cached = localModelCacheByModel.get(modelName);
36
- if (cached?.model) {
37
- return cached.model;
43
+ if (cached) {
44
+ cached.waiters++;
45
+ try {
46
+ return await waitForLocalEmbeddingModel(modelName, cached, signal);
47
+ } finally {
48
+ cached.waiters--;
49
+ deletePendingLocalModelCache(modelName, cached);
50
+ }
51
+ }
52
+ const modelPromise = (async () => {
53
+ console.log(`Loading local embedding model (${modelName})...`);
54
+ const { pipeline } = await import('@huggingface/transformers');
55
+ const model = await pipeline("feature-extraction", modelName);
56
+ console.log("Local model loaded successfully");
57
+ return model;
58
+ })();
59
+ const entry = {
60
+ promise: modelPromise,
61
+ settled: false,
62
+ waiters: 1
63
+ };
64
+ localModelCacheByModel.set(modelName, entry);
65
+ modelPromise.then(() => {
66
+ entry.settled = true;
67
+ }).catch(() => {
68
+ localModelCacheByModel.delete(modelName);
69
+ });
70
+ try {
71
+ return await waitForLocalEmbeddingModel(modelName, entry, signal);
72
+ } finally {
73
+ entry.waiters--;
74
+ deletePendingLocalModelCache(modelName, entry);
75
+ }
76
+ }
77
+ async function waitForLocalEmbeddingModel(modelName, entry, signal) {
78
+ if (signal.aborted) {
79
+ deletePendingLocalModelCache(modelName, entry);
80
+ throw providerError("local", "model inference was aborted");
81
+ }
82
+ let rejectAbort = () => {
83
+ };
84
+ const abortPromise = new Promise((_resolve, reject) => {
85
+ rejectAbort = reject;
86
+ });
87
+ const onAbort = () => {
88
+ rejectAbort(providerError("local", "model inference was aborted"));
89
+ };
90
+ signal.addEventListener("abort", onAbort, { once: true });
91
+ try {
92
+ return await Promise.race([entry.promise, abortPromise]);
93
+ } finally {
94
+ signal.removeEventListener("abort", onAbort);
38
95
  }
39
- console.log(`Loading local embedding model (${modelName})...`);
40
- const { pipeline } = await import('@xenova/transformers');
41
- const model = await pipeline("feature-extraction", modelName);
42
- localModelCacheByModel.set(modelName, { model });
43
- console.log("Local model loaded successfully");
44
- return model;
45
96
  }
46
97
  function getPositiveInteger(value, optionName) {
47
98
  if (!Number.isInteger(value) || value <= 0) {
@@ -59,6 +110,7 @@ function resolveProviderName(provider) {
59
110
  case "openai":
60
111
  case "mistral":
61
112
  case "cloudflare":
113
+ case "openai-compatible":
62
114
  return provider ?? "local";
63
115
  default:
64
116
  throw new Error(`Unknown embedding provider: ${String(provider)}`);
@@ -83,6 +135,13 @@ function getOptionalTrimmedCredential(value) {
83
135
  const trimmed = value?.trim();
84
136
  return trimmed === void 0 || trimmed.length === 0 ? void 0 : trimmed;
85
137
  }
138
+ function getRequiredTrimmedString(value, optionName) {
139
+ const trimmed = getOptionalTrimmedCredential(value);
140
+ if (!trimmed) {
141
+ throw new Error(`Invalid ${optionName}: expected a non-empty string`);
142
+ }
143
+ return trimmed;
144
+ }
86
145
  function hasOwnProperty(value, property) {
87
146
  return Object.prototype.hasOwnProperty.call(value, property);
88
147
  }
@@ -243,7 +302,27 @@ async function embedSequentially(provider, operation, texts, signal, embedOne) {
243
302
  }
244
303
  return results;
245
304
  }
246
- function createProviderMetadata(provider, dimensions) {
305
+ function normalizeOpenAICompatibleEmbeddingsUrl(baseUrl) {
306
+ let url;
307
+ try {
308
+ url = new URL(baseUrl);
309
+ } catch {
310
+ throw new Error("Invalid baseUrl: expected an absolute http or https URL");
311
+ }
312
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
313
+ throw new Error("Invalid baseUrl: expected an absolute http or https URL");
314
+ }
315
+ if (url.username || url.password) {
316
+ throw new Error("Invalid baseUrl: URL credentials are not allowed");
317
+ }
318
+ if (url.search || url.hash) {
319
+ throw new Error("Invalid baseUrl: query strings and fragments are not allowed");
320
+ }
321
+ const path = url.pathname.replace(/\/+$/, "");
322
+ url.pathname = path.endsWith("/embeddings") ? path : `${path}/embeddings`;
323
+ return url.toString();
324
+ }
325
+ function createProviderMetadata(provider, dimensions, model) {
247
326
  switch (provider) {
248
327
  case "local":
249
328
  return Object.freeze({
@@ -280,9 +359,25 @@ function createProviderMetadata(provider, dimensions) {
280
359
  dimensions: CLOUDFLARE_DIMENSIONS,
281
360
  batch: Object.freeze({ mode: "native" })
282
361
  });
362
+ case "openai-compatible":
363
+ return Object.freeze({
364
+ name: "openai-compatible",
365
+ model: getRequiredTrimmedString(model, "model"),
366
+ dimensions,
367
+ batch: Object.freeze({ mode: "native" })
368
+ });
283
369
  }
284
370
  }
285
371
  function getEffectiveDimensions(provider, dimensions) {
372
+ if (provider === "local") {
373
+ if (dimensions !== void 0 && dimensions !== LOCAL_DIMENSIONS) {
374
+ throw providerError(
375
+ "local",
376
+ `${LOCAL_MODEL} returns ${LOCAL_DIMENSIONS} dimensions; received dimensions ${String(dimensions)}`
377
+ );
378
+ }
379
+ return LOCAL_DIMENSIONS;
380
+ }
286
381
  if (provider === "gemini") {
287
382
  const effectiveDimensions = dimensions ?? GEMINI_DIMENSIONS;
288
383
  if (!Number.isInteger(effectiveDimensions) || effectiveDimensions < GEMINI_MIN_DIMENSIONS || effectiveDimensions > GEMINI_MAX_DIMENSIONS) {
@@ -311,7 +406,13 @@ function getEffectiveDimensions(provider, dimensions) {
311
406
  }
312
407
  return CLOUDFLARE_DIMENSIONS;
313
408
  }
314
- return getPositiveInteger(dimensions ?? DEFAULT_DIMENSIONS, "dimensions");
409
+ if (provider === "openai-compatible") {
410
+ if (dimensions === void 0) {
411
+ throw new Error("Invalid dimensions: expected a positive integer");
412
+ }
413
+ return getPositiveInteger(dimensions, "dimensions");
414
+ }
415
+ return getPositiveInteger(dimensions ?? OPENAI_DEFAULT_DIMENSIONS, "dimensions");
315
416
  }
316
417
  function assertBatchSize(metadata, count) {
317
418
  if (metadata.batch.maxSize !== void 0 && count > metadata.batch.maxSize) {
@@ -321,6 +422,13 @@ function assertBatchSize(metadata, count) {
321
422
  );
322
423
  }
323
424
  }
425
+ function chunkTexts(texts, batchSize) {
426
+ const chunks = [];
427
+ for (let index = 0; index < texts.length; index += batchSize) {
428
+ chunks.push(texts.slice(index, index + batchSize));
429
+ }
430
+ return chunks;
431
+ }
324
432
  function createEmbeddingBatchResult(metadata, intent, embeddings) {
325
433
  return Object.freeze({
326
434
  embeddings,
@@ -348,14 +456,13 @@ class LocalEmbeddingProvider {
348
456
  "model inference",
349
457
  this.#timeoutMs,
350
458
  async (signal) => {
351
- const model = await getLocalEmbeddingModel(this.metadata.model);
459
+ const model = await getLocalEmbeddingModel(this.metadata.model, signal);
352
460
  return embedSequentially("local", "model inference", texts, signal, async (text) => {
353
461
  const output = await model(text, {
354
462
  pooling: "mean",
355
463
  normalize: true
356
464
  });
357
- const embedding = Array.from(output.data);
358
- return padEmbedding(embedding, this.metadata.dimensions);
465
+ return Array.from(output.data);
359
466
  });
360
467
  },
361
468
  options.signal
@@ -422,18 +529,23 @@ async function requestOpenAICompatibleEmbeddings(request) {
422
529
  if (request.encodingFormat !== void 0) {
423
530
  body.encoding_format = request.encodingFormat;
424
531
  }
532
+ const headers = {
533
+ "Content-Type": "application/json"
534
+ };
535
+ if (request.apiKey !== void 0) {
536
+ headers.Authorization = `Bearer ${request.apiKey}`;
537
+ }
425
538
  const response = await fetch(request.endpoint, {
426
539
  method: "POST",
427
- headers: {
428
- "Content-Type": "application/json",
429
- "Authorization": `Bearer ${request.apiKey}`
430
- },
540
+ headers,
431
541
  signal: request.signal,
432
- body: JSON.stringify(body)
542
+ body: JSON.stringify(body),
543
+ ...request.redirect ? { redirect: request.redirect } : {}
433
544
  });
434
545
  if (!response.ok) {
435
- const requestId = getSafeResponseHeader(response, "x-request-id");
436
- const context = requestId ? ` status ${response.status}, request ${requestId}` : ` status ${response.status}`;
546
+ const statusHeader = request.statusHeader ?? { name: "x-request-id", label: "request" };
547
+ const statusHeaderValue = getSafeResponseHeader(response, statusHeader.name);
548
+ const context = statusHeaderValue ? ` status ${response.status}, ${statusHeader.label} ${statusHeaderValue}` : ` status ${response.status}`;
437
549
  throw new Error(context);
438
550
  }
439
551
  const data = await response.json();
@@ -457,92 +569,13 @@ async function requestOpenAICompatibleEmbeddings(request) {
457
569
  return parsed;
458
570
  });
459
571
  }
460
- class OpenAIEmbeddingProvider {
461
- metadata;
462
- #apiKey;
463
- #timeoutMs;
464
- constructor(metadata, apiKey, timeoutMs) {
465
- this.metadata = metadata;
466
- this.#apiKey = apiKey;
467
- this.#timeoutMs = timeoutMs;
468
- }
469
- async embed(texts, options = {}) {
470
- const intent = resolveIntent(options.intent);
471
- if (texts.length === 0) {
472
- return createEmbeddingBatchResult(this.metadata, intent, []);
473
- }
474
- assertBatchSize(this.metadata, texts.length);
475
- const items = await withTimeout(
476
- "openai",
477
- "API request",
478
- this.#timeoutMs,
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
- }),
488
- options.signal,
489
- [this.#apiKey]
490
- );
491
- return createEmbeddingBatchResult(
492
- this.metadata,
493
- intent,
494
- validateEmbeddingBatch(items, texts.length, this.metadata.dimensions, "openai")
495
- );
496
- }
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
- }
537
- class CloudflareEmbeddingProvider {
572
+ class OpenAICompatibleEmbeddingProvider {
538
573
  metadata;
539
- #accountId;
540
- #apiToken;
541
574
  #timeoutMs;
542
- constructor(metadata, accountId, apiToken, timeoutMs) {
575
+ #profile;
576
+ constructor(metadata, profile, timeoutMs) {
543
577
  this.metadata = metadata;
544
- this.#accountId = accountId;
545
- this.#apiToken = apiToken;
578
+ this.#profile = profile;
546
579
  this.#timeoutMs = timeoutMs;
547
580
  }
548
581
  async embed(texts, options = {}) {
@@ -551,53 +584,45 @@ class CloudflareEmbeddingProvider {
551
584
  return createEmbeddingBatchResult(this.metadata, intent, []);
552
585
  }
553
586
  assertBatchSize(this.metadata, texts.length);
554
- const endpoint = createCloudflareEmbeddingsUrl(this.#accountId);
555
587
  const items = await withTimeout(
556
- "cloudflare",
588
+ this.#profile.provider,
557
589
  "API request",
558
590
  this.#timeoutMs,
559
591
  async (signal) => {
560
- const response = await fetch(endpoint, {
561
- method: "POST",
562
- headers: {
563
- "Content-Type": "application/json",
564
- "Authorization": `Bearer ${this.#apiToken}`
565
- },
566
- signal,
567
- body: JSON.stringify({
568
- model: this.metadata.model,
569
- input: texts
570
- })
571
- });
572
- if (!response.ok) {
573
- const cfRay = getSafeResponseHeader(response, "cf-ray");
574
- const context = cfRay ? ` status ${response.status}, cf-ray ${cfRay}` : ` status ${response.status}`;
575
- throw new Error(context);
576
- }
577
- const data = await response.json();
578
- if (!Array.isArray(data.data)) {
579
- throw new Error("Cloudflare response did not include a data array");
592
+ const batches = this.#profile.batchSize === void 0 ? [texts] : chunkTexts(texts, this.#profile.batchSize);
593
+ const results = [];
594
+ for (const batch of batches) {
595
+ throwIfAborted(this.#profile.provider, "API request", signal);
596
+ const batchItems = await requestOpenAICompatibleEmbeddings({
597
+ responseLabel: this.#profile.responseLabel,
598
+ endpoint: this.#profile.endpoint,
599
+ ...this.#profile.apiKey !== void 0 ? { apiKey: this.#profile.apiKey } : {},
600
+ model: this.#profile.model,
601
+ texts: batch,
602
+ signal,
603
+ ...this.#profile.includeDimensions ? { dimensions: this.#profile.dimensions } : {},
604
+ ...this.#profile.encodingFormat ? { encodingFormat: this.#profile.encodingFormat } : {},
605
+ ...this.#profile.requireIndex !== void 0 ? { requireIndex: this.#profile.requireIndex } : {},
606
+ ...this.#profile.redirect ? { redirect: this.#profile.redirect } : {},
607
+ ...this.#profile.statusHeader ? { statusHeader: this.#profile.statusHeader } : {}
608
+ });
609
+ results.push(...validateEmbeddingBatch(
610
+ batchItems,
611
+ batch.length,
612
+ this.metadata.dimensions,
613
+ this.#profile.provider
614
+ ));
615
+ throwIfAborted(this.#profile.provider, "API request", signal);
580
616
  }
581
- return data.data.map((item) => {
582
- const typed = item;
583
- const embedding = typed.embedding;
584
- if (!Array.isArray(embedding)) {
585
- throw new Error("Cloudflare response item did not include an embedding array");
586
- }
587
- const parsed = { embedding };
588
- if (hasOwnProperty(typed, "index")) {
589
- parsed.index = typed.index;
590
- }
591
- return parsed;
592
- });
617
+ return results;
593
618
  },
594
619
  options.signal,
595
- [this.#apiToken, this.#accountId, encodeURIComponent(this.#accountId)]
620
+ this.#profile.exactSecrets ?? []
596
621
  );
597
622
  return createEmbeddingBatchResult(
598
623
  this.metadata,
599
624
  intent,
600
- validateEmbeddingBatch(items, texts.length, this.metadata.dimensions, "cloudflare")
625
+ validateEmbeddingBatch(items, texts.length, this.metadata.dimensions, this.#profile.provider)
601
626
  );
602
627
  }
603
628
  }
@@ -622,7 +647,16 @@ function createEmbeddingProvider(options = {}) {
622
647
  if (!key) {
623
648
  throw new Error("OPENAI_API_KEY is required for OpenAI embeddings");
624
649
  }
625
- return new OpenAIEmbeddingProvider(metadata, key, timeoutMs);
650
+ return new OpenAICompatibleEmbeddingProvider(metadata, {
651
+ provider: "openai",
652
+ responseLabel: "OpenAI",
653
+ endpoint: OPENAI_EMBEDDINGS_URL,
654
+ apiKey: key,
655
+ model: metadata.model,
656
+ dimensions: metadata.dimensions,
657
+ includeDimensions: true,
658
+ exactSecrets: [key]
659
+ }, timeoutMs);
626
660
  }
627
661
  case "mistral": {
628
662
  const key = getOptionalTrimmedCredential(
@@ -631,7 +665,17 @@ function createEmbeddingProvider(options = {}) {
631
665
  if (!key) {
632
666
  throw new Error("MISTRAL_API_KEY is required for Mistral embeddings");
633
667
  }
634
- return new MistralEmbeddingProvider(metadata, key, timeoutMs);
668
+ return new OpenAICompatibleEmbeddingProvider(metadata, {
669
+ provider: "mistral",
670
+ responseLabel: "Mistral",
671
+ endpoint: MISTRAL_EMBEDDINGS_URL,
672
+ apiKey: key,
673
+ model: metadata.model,
674
+ dimensions: metadata.dimensions,
675
+ encodingFormat: "float",
676
+ requireIndex: true,
677
+ exactSecrets: [key]
678
+ }, timeoutMs);
635
679
  }
636
680
  case "cloudflare": {
637
681
  const accountId = getOptionalTrimmedCredential(
@@ -646,13 +690,56 @@ function createEmbeddingProvider(options = {}) {
646
690
  if (!apiToken) {
647
691
  throw new Error("CLOUDFLARE_API_TOKEN is required for Cloudflare embeddings");
648
692
  }
649
- return new CloudflareEmbeddingProvider(metadata, accountId, apiToken, timeoutMs);
693
+ const endpoint = createCloudflareEmbeddingsUrl(accountId);
694
+ return new OpenAICompatibleEmbeddingProvider(metadata, {
695
+ provider: "cloudflare",
696
+ responseLabel: "Cloudflare",
697
+ endpoint,
698
+ apiKey: apiToken,
699
+ model: metadata.model,
700
+ dimensions: metadata.dimensions,
701
+ statusHeader: { name: "cf-ray", label: "cf-ray" },
702
+ exactSecrets: [apiToken, accountId, encodeURIComponent(accountId), endpoint]
703
+ }, timeoutMs);
704
+ }
705
+ case "openai-compatible": {
706
+ const baseUrl = getRequiredTrimmedString(options.baseUrl, "baseUrl");
707
+ const endpoint = normalizeOpenAICompatibleEmbeddingsUrl(baseUrl);
708
+ const apiKey = getOptionalTrimmedCredential(options.apiKey);
709
+ const batchSize = getPositiveInteger(
710
+ options.batchSize ?? OPENAI_COMPATIBLE_DEFAULT_BATCH_SIZE,
711
+ "batchSize"
712
+ );
713
+ return new OpenAICompatibleEmbeddingProvider(metadata, {
714
+ provider: "openai-compatible",
715
+ responseLabel: "OpenAI-compatible",
716
+ endpoint,
717
+ ...apiKey ? { apiKey } : {},
718
+ model: metadata.model,
719
+ dimensions: metadata.dimensions,
720
+ includeDimensions: true,
721
+ encodingFormat: "float",
722
+ requireIndex: true,
723
+ redirect: "error",
724
+ batchSize,
725
+ exactSecrets: [
726
+ ...apiKey ? [apiKey] : [],
727
+ baseUrl,
728
+ endpoint
729
+ ]
730
+ }, timeoutMs);
650
731
  }
651
732
  }
652
733
  }
653
734
  function getEmbeddingProviderMetadata(options = {}) {
654
735
  const provider = resolveProviderName(options.provider);
655
- return createProviderMetadata(provider, getEffectiveDimensions(provider, options.dimensions));
736
+ if (provider === "openai-compatible") {
737
+ normalizeOpenAICompatibleEmbeddingsUrl(getRequiredTrimmedString(options.baseUrl, "baseUrl"));
738
+ if (options.batchSize !== void 0) {
739
+ getPositiveInteger(options.batchSize, "batchSize");
740
+ }
741
+ }
742
+ return createProviderMetadata(provider, getEffectiveDimensions(provider, options.dimensions), options.model);
656
743
  }
657
744
  async function generateEmbeddings(texts, options = {}) {
658
745
  const maxLength = getPositiveInteger(options.maxLength ?? DEFAULT_MAX_LENGTH, "maxLength");
@@ -822,7 +909,7 @@ async function insertDocument(client, document, quotedTableName) {
822
909
  ]
823
910
  });
824
911
  }
825
- async function createTable(client, tableName = "articles", dimensions = 768) {
912
+ async function createTable(client, tableName = "articles", dimensions = 384) {
826
913
  const quotedTableName = quoteSqlIdentifier(tableName, "tableName");
827
914
  const vectorDimensions = normalizeVectorDimensions(dimensions);
828
915
  const quotedEmbeddingIndexName = quoteSqlIdentifier(`${tableName}_embedding_idx`, "embedding index name");
package/docs/API.md CHANGED
@@ -42,13 +42,13 @@ It also exports these types:
42
42
  Creates the table and supporting indexes used by search.
43
43
 
44
44
  ```ts
45
- await createTable(client, "articles", 768);
45
+ await createTable(client);
46
46
  ```
47
47
 
48
48
  Defaults:
49
49
 
50
50
  - `tableName`: `"articles"`
51
- - `dimensions`: `768`
51
+ - `dimensions`: `384`
52
52
 
53
53
  `tableName` must be an ASCII SQLite identifier matching
54
54
  `[A-Za-z_][A-Za-z0-9_]*`. Valid identifiers are quoted internally, so reserved
@@ -216,6 +216,10 @@ credentials or configuration.
216
216
  Gemini uses `gemini-embedding-2`. Its default is 3072 dimensions, and explicit
217
217
  Gemini dimensions must be an integer from 128 through 3072.
218
218
 
219
+ Local embeddings use `Xenova/all-MiniLM-L6-v2` through
220
+ `@huggingface/transformers` and are fixed at the model's native 384 dimensions.
221
+ Passing any other local dimension is rejected before the runtime is loaded.
222
+
219
223
  ### `getEmbeddingProviderMetadata(options?)`
220
224
 
221
225
  Returns the same metadata exposed by `createEmbeddingProvider(options).metadata`
@@ -242,7 +246,13 @@ Provider clients return:
242
246
  ```ts
243
247
  interface EmbeddingBatchResult {
244
248
  embeddings: number[][];
245
- provider: "local" | "cloudflare" | "mistral" | "gemini" | "openai";
249
+ provider:
250
+ | "local"
251
+ | "cloudflare"
252
+ | "mistral"
253
+ | "gemini"
254
+ | "openai"
255
+ | "openai-compatible";
246
256
  model: string;
247
257
  dimensions: number;
248
258
  intent: "document" | "query";
@@ -258,10 +268,19 @@ cardinality, dimensions, finite numeric values, and indexed batch ordering.
258
268
 
259
269
  ```ts
260
270
  interface EmbeddingOptions {
261
- provider?: "local" | "cloudflare" | "mistral" | "gemini" | "openai";
271
+ provider?:
272
+ | "local"
273
+ | "cloudflare"
274
+ | "mistral"
275
+ | "gemini"
276
+ | "openai"
277
+ | "openai-compatible";
262
278
  apiKey?: string;
263
279
  accountId?: string;
264
280
  apiToken?: string;
281
+ baseUrl?: string;
282
+ model?: string;
283
+ batchSize?: number;
265
284
  dimensions?: number;
266
285
  maxLength?: number;
267
286
  intent?: "document" | "query";
@@ -275,10 +294,20 @@ interface EmbeddingOptions {
275
294
  Cloudflare uses `accountId` and `apiToken`, which fall back to
276
295
  `CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_API_TOKEN`.
277
296
 
297
+ For `provider: "openai-compatible"`, callers must pass `baseUrl`, `model`, and
298
+ `dimensions`. `apiKey` is optional and is used only when explicitly provided;
299
+ the provider never reads `OPENAI_API_KEY`. `batchSize` controls the maximum
300
+ items per outbound request and defaults to `32`. The endpoint is treated as
301
+ trusted server-side configuration and should not be derived from untrusted
302
+ request input.
303
+
278
304
  ### `padEmbedding(embedding, targetDimensions)`
279
305
 
280
306
  Pads or truncates an embedding array to the requested length.
281
307
 
308
+ This helper remains exported for callers that used it directly. The local
309
+ provider does not use it; local vectors are validated at native 384 dimensions.
310
+
282
311
  ### `prepareTextForEmbedding(fields)`
283
312
 
284
313
  Combines title, description, tags, and content into the text sent to the
package/docs/INDEXING.md CHANGED
@@ -25,7 +25,6 @@ await indexContent({
25
25
  tableName: "articles",
26
26
  embeddingOptions: {
27
27
  provider: "local",
28
- dimensions: 768,
29
28
  },
30
29
  });
31
30
  ```
@@ -34,6 +33,11 @@ That keeps the implementation simple, but it also means a failed rebuild can
34
33
  leave the index partially repopulated.
35
34
 
36
35
  Changing an embedding provider or dimension count requires a full re-embed.
36
+ For existing local indexes created with the older padded-local behavior, create
37
+ or recreate a 384-dimensional table before rebuilding. Those older local
38
+ 768-dimensional tables stored 384 model values followed by zero padding;
39
+ `indexContent()` clears rows but does not change the table's `F32_BLOB` width.
40
+
37
41
  For Gemini specifically, indexes created with the retired `text-embedding-004`
38
42
  model must be rebuilt for `gemini-embedding-2` even when staying at 768
39
43
  dimensions, because the model and query/document formatting both changed. If
@@ -72,7 +76,7 @@ database calls or embedding generation.
72
76
 
73
77
  ## Runtime Notes
74
78
 
75
- - local embeddings may download a model on the first run
79
+ - local embeddings may download and cache a model on the first run
76
80
  - Node users need `@libsql/client` installed alongside the package
77
81
  - the repository validates both the npm package build and `deno check`, but the
78
82
  indexing flow itself still depends on filesystem access
@@ -26,7 +26,6 @@ export const POST: APIRoute = async ({ request }) => {
26
26
  limit,
27
27
  embeddingOptions: {
28
28
  provider: "local",
29
- dimensions: 768,
30
29
  },
31
30
  });
32
31
 
@@ -79,7 +78,6 @@ export async function POST(request: NextRequest) {
79
78
  limit,
80
79
  embeddingOptions: {
81
80
  provider: "local",
82
- dimensions: 768,
83
81
  },
84
82
  });
85
83
 
@@ -134,13 +132,21 @@ const embeddingProvider =
134
132
  | "mistral"
135
133
  | "gemini"
136
134
  | "openai"
135
+ | "openai-compatible"
137
136
  | undefined;
137
+
138
+ const dimensionsByProvider = {
139
+ local: 384,
140
+ cloudflare: 1024,
141
+ mistral: 1024,
142
+ gemini: 3072,
143
+ openai: 1536,
144
+ } as const;
145
+
138
146
  const embeddingDimensions =
139
- embeddingProvider === "cloudflare" || embeddingProvider === "mistral"
140
- ? 1024
141
- : embeddingProvider === "gemini"
142
- ? 3072
143
- : 768;
147
+ embeddingProvider === "openai-compatible"
148
+ ? Number(process.env.EMBEDDING_DIMENSIONS)
149
+ : dimensionsByProvider[embeddingProvider ?? "local"];
144
150
 
145
151
  await createTable(client, "articles", embeddingDimensions);
146
152
 
@@ -149,6 +155,8 @@ await indexContent({
149
155
  contentPath: "./content",
150
156
  embeddingOptions: {
151
157
  provider: embeddingProvider,
158
+ baseUrl: process.env.EMBEDDING_BASE_URL,
159
+ model: process.env.EMBEDDING_MODEL,
152
160
  dimensions: embeddingDimensions,
153
161
  },
154
162
  });
@@ -156,3 +164,9 @@ await indexContent({
156
164
 
157
165
  Pair this with your framework build command so indexed content and deployed code
158
166
  stay in sync.
167
+
168
+ When you use `openai-compatible`, keep `EMBEDDING_BASE_URL` as trusted
169
+ server-side configuration, not user request input. Recreate the vector table or
170
+ rebuild into a separate table whenever the provider, endpoint, model, or
171
+ dimension count changes; stored vectors and query vectors must come from the
172
+ same embedding space.