@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/CHANGELOG.md +101 -1
- package/README.md +167 -6
- package/index.ts +78 -4
- package/install.sh +0 -0
- package/package.json +1 -1
- package/src/config.ts +31 -5
- package/src/embedder.ts +339 -99
- package/src/indexer.ts +12 -1
- package/src/lancedb-store.ts +2 -3
- package/src/lancedb-support.ts +83 -0
- package/src/registry.ts +105 -19
- package/src/rpc.ts +1 -2
- package/src/search.ts +41 -2
- package/src/settings-commands.ts +6 -4
- package/src/settings.ts +28 -13
- package/src/status.ts +53 -5
- package/src/types.ts +41 -0
- package/tui/settings-page-data.ts +7 -8
- package/tui.ts +50 -9
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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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 {
|
|
23
|
-
|
|
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
|
-
/**
|
|
37
|
-
const
|
|
38
|
-
/**
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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 *
|
|
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
|
-
/**
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
/**
|
|
201
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
242
|
-
|
|
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
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
|
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(
|
|
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
|
|
327
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
package/src/lancedb-store.ts
CHANGED
|
@@ -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
|
}
|