@javierkaiser/opencode-indexing 0.1.0 → 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/src/embedder.ts CHANGED
@@ -1,48 +1,132 @@
1
1
  /**
2
2
  * Embedding providers implemented with the global `fetch` (no SDKs).
3
3
  *
4
- * Mirrors Kilo Code's request/response shapes:
5
- * - mistral / openai-compatible request `encoding_format: "base64"` and accept
6
- * either base64-encoded Float32 vectors or plain number arrays.
7
- * - openai requests plain float arrays (a base64 string is tolerated anyway,
8
- * some gateways return it).
9
- * - ollama uses the newer `/api/embed` batch endpoint.
4
+ * Four of the seven share one wire shape - `POST {base}/embeddings`, bearer
5
+ * auth, `{ input, model }` in and `{ data: [{ embedding, index }] }` out - so
6
+ * they run through a single parameterised adapter rather than four near-copies:
7
+ * OpenAI, Mistral, Voyage and OpenRouter. Gemini and Ollama differ enough to
8
+ * need their own: Gemini authenticates with a header, takes its own request
9
+ * shape and returns vectors under a different key, and Ollama has no auth and
10
+ * returns a bare positional array.
10
11
  *
11
- * Batching: texts are split into requests of at most `embeddingBatchSize`
12
- * items and at most `MAX_BATCH_TOKENS` estimated tokens (ceil(chars / 4)).
13
- * Unlike Kilo (which drops oversized texts and thereby desynchronizes the
14
- * embedding count), items estimated above `MAX_ITEM_TOKENS` are truncated to
15
- * `MAX_ITEM_CHARS` characters, so input/output alignment is always preserved.
12
+ * Batching is bounded per provider. A single global item count was wrong the
13
+ * moment more than one provider existed: OpenAI caps a request at 300,000
14
+ * tokens regardless of item count, Gemini accepts a fixed number of items per
15
+ * call, and Voyage has both ceilings. Bounds come from each provider's own
16
+ * documentation and are combined with the user's `embeddingBatchSize`.
17
+ *
18
+ * Unlike Kilo - which drops oversized texts and thereby desynchronizes the
19
+ * embedding count - items above the model's input ceiling are truncated, so
20
+ * input/output alignment is always preserved.
16
21
  *
17
22
  * Retries: up to `MAX_ATTEMPTS` attempts per batch for HTTP 429/5xx and
18
23
  * network failures (including timeouts), with exponential backoff
19
24
  * `500ms * 2^attempt`. Other 4xx responses fail immediately.
20
25
  */
21
26
 
22
- import { DEFAULT_EMBEDDING_BATCH_SIZE, getModelDimension, getModelQueryPrefix } from "./registry.ts"
23
- import type { Embedder, IndexingSettings } from "./types.ts"
27
+ import {
28
+ DEFAULT_EMBEDDING_BATCH_SIZE,
29
+ getModelDimension,
30
+ getModelMaxInputTokens,
31
+ getModelProfile,
32
+ getModelQueryPrefix,
33
+ } from "./registry.ts"
34
+ import type { Embedder, EmbedderProvider, IndexingSettings } from "./types.ts"
24
35
  import { describeError } from "./errors.ts"
25
36
 
26
- const MISTRAL_EMBEDDINGS_URL = "https://api.mistral.ai/v1/embeddings"
27
- const OPENAI_EMBEDDINGS_URL = "https://api.openai.com/v1/embeddings"
28
- const DEFAULT_OLLAMA_BASE_URL = "http://localhost:11434"
29
-
30
37
  const REQUEST_TIMEOUT_MS = 60_000
31
38
  const OLLAMA_TIMEOUT_MS = 120_000
32
39
 
33
40
  const MAX_ATTEMPTS = 3
34
41
  const RETRY_BASE_DELAY_MS = 500
35
42
 
36
- /** Model token limit (all catalog models). */
37
- const MAX_ITEM_TOKENS = 8191
38
- /** ~4 chars/token with a safety margin below MAX_ITEM_TOKENS. */
39
- const MAX_ITEM_CHARS = 32_000
40
- /** Token budget for a single request. */
41
- const MAX_BATCH_TOKENS = 100_000
43
+ /** ~4 characters per token, the same estimate the batch budget uses. */
44
+ const CHARS_PER_TOKEN = 4
45
+ /**
46
+ * Fraction of the model's documented input ceiling we are willing to send.
47
+ *
48
+ * The characters-per-token figure is an estimate, so filling a chunk to exactly
49
+ * the ceiling risks a rejection from any model whose tokenizer is slightly
50
+ * denser than assumed. The cost of this margin is a few percent of a chunk; the
51
+ * cost of getting it wrong is a whole indexing run failing on a long file.
52
+ */
53
+ const INPUT_SAFETY = 0.95
54
+ /** Token budget for a request when neither the user nor the provider sets one. */
55
+ const FALLBACK_MAX_BATCH_TOKENS = 100_000
56
+ /** Item ceiling for a request when neither the user nor the provider sets one. */
57
+ const FALLBACK_MAX_BATCH_ITEMS = 64
42
58
  /** Do not prepend a prefix when it would blow up an otherwise valid text. */
43
- const MAX_PREFIXED_CHARS = 4 * MAX_ITEM_TOKENS
59
+ const MAX_PREFIXED_CHARS = 4 * 8_192
44
60
  const ERROR_BODY_SNIPPET_CHARS = 500
45
61
 
62
+ /** Name of the request field that asks a provider for a specific output width. */
63
+ type DimensionParam = "dimensions" | "output_dimension" | "none"
64
+
65
+ interface ProviderSpec {
66
+ /** Label used in retries and error messages. */
67
+ label: string
68
+ /** Endpoint, ignoring any per-provider variation resolved at call time. */
69
+ url: string
70
+ /** Header carrying the credential, or undefined for an unauthenticated local server. */
71
+ authHeader?: string
72
+ /** Requested response encoding, where asking for it is worth the bytes. */
73
+ encodingFormat?: "base64"
74
+ /** Defaults merged into every request body. */
75
+ baseBody?: Record<string, unknown>
76
+ /** Refuses over-long input instead of truncating it, where the API offers the choice. */
77
+ refuseOversizedInput?: boolean
78
+ }
79
+
80
+ const PROVIDER_SPECS: Partial<Record<EmbedderProvider, ProviderSpec>> = {
81
+ openai: {
82
+ label: "openai",
83
+ url: "https://api.openai.com/v1/embeddings",
84
+ authHeader: "Authorization",
85
+ },
86
+ mistral: {
87
+ label: "mistral",
88
+ url: "https://api.mistral.ai/v1/embeddings",
89
+ authHeader: "Authorization",
90
+ encodingFormat: "base64",
91
+ },
92
+ voyage: {
93
+ label: "voyage",
94
+ url: "https://api.voyageai.com/v1/embeddings",
95
+ authHeader: "Authorization",
96
+ // Voyage documents that embeddings made with and without `input_type` are
97
+ // interchangeable, so it can be left off without breaking a Kilo-imported
98
+ // index. Setting it to "document" is the single largest quality lever the
99
+ // provider offers for retrieval, so it is on for documents.
100
+ baseBody: { input_type: "document", truncation: false },
101
+ },
102
+ openrouter: {
103
+ label: "openrouter",
104
+ url: "https://openrouter.ai/api/v1/embeddings",
105
+ authHeader: "Authorization",
106
+ },
107
+ gemini: {
108
+ label: "gemini",
109
+ url: "",
110
+ authHeader: "x-goog-api-key",
111
+ },
112
+ // The URL is whatever the user configured, resolved at call time. Asking for
113
+ // base64 is the same gamble this adapter makes for Mistral; the response
114
+ // parser accepts either form, so a server that ignores the field still works.
115
+ "openai-compatible": {
116
+ label: "openai-compatible",
117
+ url: "",
118
+ authHeader: "Authorization",
119
+ encodingFormat: "base64",
120
+ },
121
+ ollama: {
122
+ label: "ollama",
123
+ url: "",
124
+ // Truncating a code chunk without saying so produces a vector that looks
125
+ // fine and retrieves the wrong thing forever, so the failure has to be loud.
126
+ baseBody: { truncate: false },
127
+ },
128
+ }
129
+
46
130
  class HttpError extends Error {
47
131
  readonly status: number
48
132
 
@@ -153,15 +237,49 @@ function parseVector(value: unknown): number[] {
153
237
  throw new Error("unexpected embedding format (expected number[] or base64 string)")
154
238
  }
155
239
 
156
- /** Parse an OpenAI/mistral-style `{ data: [{ embedding }] }` payload. */
240
+ /**
241
+ * Parse an OpenAI-shaped `{ data: [{ embedding, index }] }` payload.
242
+ *
243
+ * Results are placed by `index`, not by array position. OpenAI's own samples
244
+ * fetch by `embedding.index`, and its documentation describes the field rather
245
+ * than promising order, so trusting the position is trusting something the
246
+ * vendor never did. A response with no `index` on any entry keeps its order,
247
+ * which is the best that can be done for a gateway that omits it.
248
+ */
157
249
  function parseDataEmbeddings(payload: unknown, expected: number): number[][] {
158
250
  const data = (payload as { data?: unknown } | null)?.data
159
251
  if (!Array.isArray(data)) throw new Error("response is missing the data array")
160
- const embeddings = data.map((entry) => parseVector((entry as { embedding?: unknown } | null)?.embedding))
161
- if (embeddings.length !== expected) {
162
- throw new Error(`embedding count mismatch (${embeddings.length} != ${expected})`)
252
+ if (data.length !== expected) {
253
+ throw new Error(`embedding count mismatch (${data.length} != ${expected})`)
254
+ }
255
+
256
+ const entries = data.map((entry, position) => ({
257
+ position,
258
+ index: toFiniteIndex((entry as { index?: unknown } | null)?.index),
259
+ vector: parseVector((entry as { embedding?: unknown } | null)?.embedding),
260
+ }))
261
+
262
+ const indexed = entries.every((entry) => entry.index !== undefined)
263
+ if (indexed) {
264
+ const out: (number[] | undefined)[] = new Array(expected)
265
+ for (const entry of entries) {
266
+ const index = entry.index as number
267
+ if (index < 0 || index >= expected || out[index] !== undefined) {
268
+ throw new Error(`response returned an out-of-range or repeated embedding index (${index})`)
269
+ }
270
+ out[index] = entry.vector
271
+ }
272
+ if (out.some((vector) => vector === undefined)) {
273
+ throw new Error("response is missing one or more embedding indices")
274
+ }
275
+ return out as number[][]
163
276
  }
164
- return embeddings
277
+
278
+ return entries.map((entry) => entry.vector)
279
+ }
280
+
281
+ function toFiniteIndex(value: unknown): number | undefined {
282
+ return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : undefined
165
283
  }
166
284
 
167
285
  /** Parse Ollama's `{ embeddings: number[][] }` payload. */
@@ -178,6 +296,26 @@ function parseOllamaEmbeddings(payload: unknown, expected: number): number[][] {
178
296
  return vectors
179
297
  }
180
298
 
299
+ /**
300
+ * The shape Gemini's batch endpoint returns: `{ embeddings: [{ values }] }`.
301
+ *
302
+ * `values` rather than `embedding`, and documented as coming back in the same
303
+ * order as the requests. There is no `index` to verify that against, so the
304
+ * count check is the only guard there is.
305
+ */
306
+ function parseGeminiEmbeddings(payload: unknown, expected: number): number[][] {
307
+ const embeddings = (payload as { embeddings?: unknown } | null)?.embeddings
308
+ if (!Array.isArray(embeddings)) throw new Error("response is missing the embeddings array")
309
+ if (embeddings.length !== expected) {
310
+ throw new Error(`embedding count mismatch (${embeddings.length} != ${expected})`)
311
+ }
312
+ return embeddings.map((entry, index) => {
313
+ const values = (entry as { values?: unknown } | null)?.values
314
+ if (!Array.isArray(values)) throw new Error(`embedding ${index} has no values array`)
315
+ return values.map((value) => Number(value))
316
+ })
317
+ }
318
+
181
319
  /**
182
320
  * Apply the model query prefix to every text. Already-prefixed texts are left
183
321
  * untouched (Kilo applies the same prefix for indexing and querying). Texts
@@ -192,21 +330,35 @@ function applyPrefix(texts: readonly string[], prefix: string | undefined): stri
192
330
  })
193
331
  }
194
332
 
195
- function fitToTokenLimit(text: string): string {
196
- if (Math.ceil(text.length / 4) <= MAX_ITEM_TOKENS) return text
197
- return text.slice(0, MAX_ITEM_CHARS)
333
+ /** Per-request ceilings, after combining the user's setting with the provider's. */
334
+ export interface BatchLimits {
335
+ maxItems: number
336
+ maxTokens: number
198
337
  }
199
338
 
200
- /** Split texts into batches bounded by item count and estimated token budget. */
201
- function buildBatches(texts: string[], batchSize: number): string[][] {
339
+ /**
340
+ * Split texts into batches bounded by item count and estimated token budget.
341
+ *
342
+ * Both ceilings are per provider. OpenAI accepts at most 300,000 tokens in one
343
+ * request however many items that is, so at 8,192 tokens per chunk a batch is
344
+ * about 36 items - well under the configured `embeddingBatchSize` of 60, and
345
+ * a fixed item count would have produced 400,000 tokens and a 400.
346
+ */
347
+ function buildBatches(texts: string[], limits: BatchLimits, maxItemTokens: number): string[][] {
348
+ const maxItemChars = Math.floor(maxItemTokens * CHARS_PER_TOKEN * INPUT_SAFETY)
202
349
  const batches: string[][] = []
203
350
  let current: string[] = []
204
351
  let currentTokens = 0
205
352
 
206
353
  for (const raw of texts) {
207
- const text = fitToTokenLimit(raw)
208
- const tokens = Math.ceil(text.length / 4)
209
- if (current.length > 0 && (current.length >= batchSize || currentTokens + tokens > MAX_BATCH_TOKENS)) {
354
+ // Truncating keeps the output aligned with the input, which dropping the
355
+ // item would not.
356
+ const text = raw.length <= maxItemChars ? raw : raw.slice(0, maxItemChars)
357
+ const tokens = Math.ceil(text.length / CHARS_PER_TOKEN)
358
+ if (
359
+ current.length > 0 &&
360
+ (current.length >= limits.maxItems || currentTokens + tokens > limits.maxTokens)
361
+ ) {
210
362
  batches.push(current)
211
363
  current = []
212
364
  currentTokens = 0
@@ -238,80 +390,142 @@ function requireCredential(value: string | undefined, description: string): stri
238
390
 
239
391
  type BatchRequest = (texts: string[]) => Promise<number[][]>
240
392
 
241
- function createMistralRequest(settings: IndexingSettings): BatchRequest {
242
- const apiKey = requireCredential(settings.credentials.mistralApiKey, "Mistral API key (credentials.mistralApiKey)")
393
+ /**
394
+ * One adapter for every provider that speaks the OpenAI wire shape.
395
+ *
396
+ * The endpoint, the auth header and a couple of optional body fields are the
397
+ * only things that differ between OpenAI, Mistral, Voyage, OpenRouter and an
398
+ * arbitrary OpenAI-compatible server, so they are what varies. Mistral's raw
399
+ * HTTP body takes `input` (singular) even though every Mistral SDK and every
400
+ * example in their documentation uses `inputs`; generating the body from those
401
+ * examples sends the wrong field and produces an empty input.
402
+ */
403
+ function createOpenAIShapedRequestImpl(
404
+ spec: ProviderSpec,
405
+ settings: IndexingSettings,
406
+ options: { url: string; auth?: string },
407
+ ): BatchRequest {
243
408
  const modelId = settings.modelId
409
+ const headers: Record<string, string> = {}
410
+ if (spec.authHeader && options.auth) headers[spec.authHeader] = `Bearer ${options.auth}`
411
+
244
412
  return async (texts) => {
245
- const payload = await withRetries("mistral", () =>
246
- fetchJson(
247
- MISTRAL_EMBEDDINGS_URL,
248
- {
249
- method: "POST",
250
- headers: { Authorization: `Bearer ${apiKey}` },
251
- body: JSON.stringify({ model: modelId, input: texts, encoding_format: "base64" }),
252
- },
253
- REQUEST_TIMEOUT_MS,
254
- ),
413
+ const body: Record<string, unknown> = {
414
+ ...spec.baseBody,
415
+ model: modelId,
416
+ input: texts,
417
+ ...(spec.encodingFormat ? { encoding_format: spec.encodingFormat } : {}),
418
+ }
419
+ const payload = await withRetries(spec.label, () =>
420
+ fetchJson(options.url, { method: "POST", headers, body: JSON.stringify(body) }, REQUEST_TIMEOUT_MS),
255
421
  )
256
422
  return parseDataEmbeddings(payload, texts.length)
257
423
  }
258
424
  }
259
425
 
426
+ function createMistralRequest(settings: IndexingSettings): BatchRequest {
427
+ const spec = requireSpec("mistral")
428
+ return createOpenAIShapedRequestImpl(spec, settings, {
429
+ url: spec.url,
430
+ auth: requireCredential(settings.credentials.mistralApiKey, "Mistral API key (credentials.mistralApiKey)"),
431
+ })
432
+ }
433
+
260
434
  function createOpenAiRequest(settings: IndexingSettings): BatchRequest {
261
- const apiKey = requireCredential(settings.credentials.openAiApiKey, "OpenAI API key (credentials.openAiApiKey)")
262
- const modelId = settings.modelId
263
- return async (texts) => {
264
- const payload = await withRetries("openai", () =>
265
- fetchJson(
266
- OPENAI_EMBEDDINGS_URL,
267
- {
268
- method: "POST",
269
- headers: { Authorization: `Bearer ${apiKey}` },
270
- body: JSON.stringify({ model: modelId, input: texts }),
271
- },
272
- REQUEST_TIMEOUT_MS,
273
- ),
274
- )
275
- return parseDataEmbeddings(payload, texts.length)
276
- }
435
+ const spec = requireSpec("openai")
436
+ return createOpenAIShapedRequestImpl(spec, settings, {
437
+ url: spec.url,
438
+ auth: requireCredential(settings.credentials.openAiApiKey, "OpenAI API key (credentials.openAiApiKey)"),
439
+ })
440
+ }
441
+
442
+ function createVoyageRequest(settings: IndexingSettings): BatchRequest {
443
+ const spec = requireSpec("voyage")
444
+ return createOpenAIShapedRequestImpl(spec, settings, {
445
+ url: spec.url,
446
+ auth: requireCredential(settings.credentials.voyageApiKey, "Voyage API key (credentials.voyageApiKey)"),
447
+ })
448
+ }
449
+
450
+ function createOpenRouterRequest(settings: IndexingSettings): BatchRequest {
451
+ const spec = requireSpec("openrouter")
452
+ return createOpenAIShapedRequestImpl(spec, settings, {
453
+ url: spec.url,
454
+ auth: requireCredential(settings.credentials.openRouterApiKey, "OpenRouter API key (credentials.openRouterApiKey)"),
455
+ })
277
456
  }
278
457
 
279
458
  function createOpenAiCompatibleRequest(settings: IndexingSettings): BatchRequest {
459
+ const spec = requireSpec("openai-compatible")
280
460
  const baseUrl = requireCredential(
281
461
  settings.credentials.openAiCompatibleBaseUrl,
282
462
  "OpenAI-compatible base URL (credentials.openAiCompatibleBaseUrl)",
283
463
  )
284
- const endpoint = resolveEmbeddingsEndpoint(baseUrl)
285
- const apiKey = settings.credentials.openAiCompatibleApiKey?.trim()
286
- const modelId = settings.modelId
464
+ // A generic endpoint may or may not honour `encoding_format`; asking is the
465
+ // same gamble this adapter already makes for Mistral, and the response parser
466
+ // accepts either form, so a server that ignores it still works.
467
+ return createOpenAIShapedRequestImpl(
468
+ spec,
469
+ settings,
470
+ { url: resolveEmbeddingsEndpoint(baseUrl), auth: settings.credentials.openAiCompatibleApiKey?.trim() },
471
+ )
472
+ }
473
+
474
+ const GEMINI_API_BASE = "https://generativelanguage.googleapis.com/v1beta"
475
+
476
+ /**
477
+ * Gemini's batch endpoint, with every input wrapped in its own `Content`.
478
+ *
479
+ * The wrapping is not decoration. `gemini-embedding-001` embeds a list of
480
+ * strings one vector per string, but `gemini-embedding-2` aggregates a plain
481
+ * list into a single vector. Sending the list unwrapped therefore returns one
482
+ * vector for N inputs and writes N-1 missing chunks into the index, with no
483
+ * error anywhere. Wrapping makes both models behave the same way.
484
+ */
485
+ function createGeminiRequest(settings: IndexingSettings): BatchRequest {
486
+ const spec = requireSpec("gemini")
487
+ const apiKey = requireCredential(settings.credentials.geminiApiKey, "Gemini API key (credentials.geminiApiKey)")
488
+ const model = encodeURIComponent(settings.modelId)
489
+ const url = `${GEMINI_API_BASE}/models/${model}:batchEmbedContents`
490
+ const headers = { [spec.authHeader as string]: apiKey }
491
+
287
492
  return async (texts) => {
288
- const headers: Record<string, string> = {}
289
- if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`
290
- const payload = await withRetries("openai-compatible", () =>
291
- fetchJson(
292
- endpoint,
293
- {
294
- method: "POST",
295
- headers,
296
- body: JSON.stringify({ model: modelId, input: texts, encoding_format: "base64" }),
297
- },
298
- REQUEST_TIMEOUT_MS,
299
- ),
493
+ const body = {
494
+ requests: texts.map((text) => ({
495
+ model: `models/${settings.modelId}`,
496
+ content: { parts: [{ text }] },
497
+ })),
498
+ }
499
+ const payload = await withRetries(spec.label, () =>
500
+ fetchJson(url, { method: "POST", headers, body: JSON.stringify(body) }, REQUEST_TIMEOUT_MS),
300
501
  )
301
- return parseDataEmbeddings(payload, texts.length)
502
+ return parseGeminiEmbeddings(payload, texts.length)
302
503
  }
303
504
  }
304
505
 
506
+ const DEFAULT_OLLAMA_BASE_URL = "http://localhost:11434"
507
+
508
+ /**
509
+ * Ollama's `/api/embed`, which returns a bare positional array.
510
+ *
511
+ * There is no `index` field and no documented ordering guarantee, so
512
+ * correspondence is positional and unverifiable. `truncate: false` makes an
513
+ * over-long chunk fail loudly: with the default of `true`, `mxbai-embed-large`
514
+ * would quietly embed the first 512 tokens of every function in the repository
515
+ * and store the result as though it were the whole thing.
516
+ */
305
517
  function createOllamaRequest(settings: IndexingSettings): BatchRequest {
518
+ const spec = requireSpec("ollama")
306
519
  const configured = settings.credentials.ollamaBaseUrl?.trim()
307
520
  const baseUrl = stripTrailingSlashes(configured && configured.length > 0 ? configured : DEFAULT_OLLAMA_BASE_URL)
308
521
  const endpoint = `${baseUrl}/api/embed`
309
522
  const modelId = settings.modelId
523
+
310
524
  return async (texts) => {
311
- const payload = await withRetries("ollama", () =>
525
+ const payload = await withRetries(spec.label, () =>
312
526
  fetchJson(
313
527
  endpoint,
314
- { method: "POST", body: JSON.stringify({ model: modelId, input: texts }) },
528
+ { method: "POST", body: JSON.stringify({ ...spec.baseBody, model: modelId, input: texts }) },
315
529
  OLLAMA_TIMEOUT_MS,
316
530
  ),
317
531
  )
@@ -319,14 +533,38 @@ function createOllamaRequest(settings: IndexingSettings): BatchRequest {
319
533
  }
320
534
  }
321
535
 
536
+ function requireSpec(provider: EmbedderProvider): ProviderSpec {
537
+ const spec = PROVIDER_SPECS[provider]
538
+ if (!spec) throw new Error(`Unsupported embedder provider: ${provider}`)
539
+ return spec
540
+ }
541
+
542
+ /**
543
+ * Combine the user's `embeddingBatchSize` with the provider's own ceilings.
544
+ *
545
+ * `embeddingBatchSize` is an item ceiling, so it is compared against the
546
+ * provider's item ceiling. The token ceiling is not derived from the item
547
+ * count: the two are independent limits, and deriving one from the other
548
+ * silently caps throughput at whatever arithmetic happens to produce.
549
+ */
550
+ export function resolveBatchLimits(settings: IndexingSettings): BatchLimits {
551
+ const profile = getModelProfile(settings.provider, settings.modelId)
552
+ const configured =
553
+ Number.isFinite(settings.embeddingBatchSize) && settings.embeddingBatchSize > 0
554
+ ? Math.floor(settings.embeddingBatchSize)
555
+ : DEFAULT_EMBEDDING_BATCH_SIZE
556
+ return {
557
+ maxItems: Math.max(1, Math.min(configured, profile?.maxBatchItems ?? FALLBACK_MAX_BATCH_ITEMS)),
558
+ maxTokens: Math.max(1, profile?.maxBatchTokens ?? FALLBACK_MAX_BATCH_TOKENS),
559
+ }
560
+ }
561
+
322
562
  function createEmbedderImpl(settings: IndexingSettings, request: BatchRequest, prefix: string | undefined): Embedder {
323
563
  const provider = settings.provider
324
564
  const modelId = settings.modelId
325
565
  const dimension = settings.dimension || getModelDimension(provider, modelId) || 0
326
- const batchSize =
327
- Number.isFinite(settings.embeddingBatchSize) && settings.embeddingBatchSize > 0
328
- ? Math.floor(settings.embeddingBatchSize)
329
- : DEFAULT_EMBEDDING_BATCH_SIZE
566
+ const limits = resolveBatchLimits(settings)
567
+ const maxItemTokens = getModelMaxInputTokens(provider, modelId)
330
568
 
331
569
  return {
332
570
  provider,
@@ -334,7 +572,7 @@ function createEmbedderImpl(settings: IndexingSettings, request: BatchRequest, p
334
572
  dimension,
335
573
  async embed(texts) {
336
574
  const prepared = applyPrefix(texts, prefix)
337
- const batches = buildBatches(prepared, batchSize)
575
+ const batches = buildBatches(prepared, limits, maxItemTokens)
338
576
  const vectors: number[][] = []
339
577
  for (const batch of batches) {
340
578
  const embedded = await request(batch)
@@ -360,24 +598,26 @@ function createEmbedderImpl(settings: IndexingSettings, request: BatchRequest, p
360
598
  */
361
599
  export function createEmbedder(settings: IndexingSettings): Embedder {
362
600
  const provider = settings.provider
601
+ // The prefix comes from the catalog for models that declare one (nomic and
602
+ // friends); otherwise the resolved setting, which only an explicit override
603
+ // sets. Mistral and OpenAI declare none.
604
+ const prefix = getModelQueryPrefix(provider, settings.modelId) ?? settings.queryPrefix
363
605
  switch (provider) {
364
606
  case "mistral":
365
607
  return createEmbedderImpl(settings, createMistralRequest(settings), undefined)
366
608
  case "openai":
367
609
  return createEmbedderImpl(settings, createOpenAiRequest(settings), undefined)
610
+ case "voyage":
611
+ return createEmbedderImpl(settings, createVoyageRequest(settings), undefined)
612
+ case "openrouter":
613
+ return createEmbedderImpl(settings, createOpenRouterRequest(settings), undefined)
614
+ case "gemini":
615
+ return createEmbedderImpl(settings, createGeminiRequest(settings), undefined)
368
616
  case "openai-compatible":
369
- return createEmbedderImpl(
370
- settings,
371
- createOpenAiCompatibleRequest(settings),
372
- getModelQueryPrefix(provider, settings.modelId) ?? settings.queryPrefix,
373
- )
617
+ return createEmbedderImpl(settings, createOpenAiCompatibleRequest(settings), prefix)
374
618
  case "ollama":
375
- return createEmbedderImpl(
376
- settings,
377
- createOllamaRequest(settings),
378
- getModelQueryPrefix(provider, settings.modelId) ?? settings.queryPrefix,
379
- )
619
+ return createEmbedderImpl(settings, createOllamaRequest(settings), prefix)
380
620
  default:
381
- throw new Error(`Unsupported embedder provider: ${provider}`)
621
+ throw new Error(`Unsupported embedder provider: ${provider as string}`)
382
622
  }
383
623
  }
package/src/indexer.ts CHANGED
@@ -95,7 +95,18 @@ export async function runIndex(deps: IndexerDeps, options: IndexOptions): Promis
95
95
  // Determine dimension (probe embedder if unknown).
96
96
  let dimension = settings.dimension
97
97
  const existingInfo = await store.info()
98
- if (!dimension && existingInfo.profile?.dimension) dimension = existingInfo.profile.dimension
98
+ const existingProfile = existingInfo.profile
99
+ // The stored dimension is only authoritative for the profile we are about to
100
+ // write. Inheriting it across a provider change is how a 1536-wide Mistral
101
+ // index makes an OpenAI model look 1536-dimensional: the probe that would
102
+ // have asked the provider never runs, and the inherited number is then
103
+ // persisted as though it had been measured, which makes the mismatch
104
+ // self-consistent and undetectable on every later run.
105
+ const sameProfile =
106
+ existingProfile !== undefined &&
107
+ existingProfile.provider === settings.provider &&
108
+ existingProfile.modelId === settings.modelId
109
+ if (!dimension && sameProfile && existingProfile?.dimension) dimension = existingProfile.dimension
99
110
  if (!dimension) {
100
111
  const probe = await getEmbedder().embed(["dimension probe"])
101
112
  dimension = probe[0]?.length ?? 0
@@ -3,6 +3,7 @@ import * as path from "node:path"
3
3
 
4
4
  import { workspaceHash } from "./registry.ts"
5
5
  import { defaultLanceDbDirectory } from "./settings.ts"
6
+ import { lanceDbUnavailableMessage } from "./lancedb-support.ts"
6
7
  import type { VectorStoreAdapter, VectorStoreInfo, StoreSearchOptions, ExportBatch } from "./vector-store.ts"
7
8
  import type { EmbeddingProfile, QdrantPoint, QueryHit } from "./types.ts"
8
9
  import { describeError } from "./errors.ts"
@@ -71,9 +72,7 @@ let lancedbPromise: Promise<any> | undefined
71
72
  function loadLanceDb(): Promise<any> {
72
73
  lancedbPromise ??= import("@lancedb/lancedb").catch((error) => {
73
74
  lancedbPromise = undefined
74
- throw new Error(
75
- `LanceDB no está disponible (instala @lancedb/lancedb@0.26.2 o usa vectorStore=qdrant): ${describeError(error)}`,
76
- )
75
+ throw new Error(lanceDbUnavailableMessage(error), { cause: error })
77
76
  })
78
77
  return lancedbPromise
79
78
  }