This commit is contained in:
NotExpectedYet committed 2026-09-26 05:35:29 +01:00
commit 0964819cdc
34 files changed
+7912

No files matched your search

+268
View File
@@ -0,0 +1,268 @@
/**
* The embedding client.
*
* Speaks the Ollama HTTP API, which also covers any OpenAI-compatible gateway
* that implements `/api/embed`, and is deliberately the *only* outbound call
* this plugin makes. There is no vendor SDK and no configuration surface that
* could redirect code anywhere except the endpoint the operator named.
*
* Two properties the rest of the plugin depends on:
*
* **Every vector is unit length.** Cosine similarity is then a plain dot
* product, which is what makes ranking over tens of thousands of chunks a few
* milliseconds of arithmetic rather than a normalisation pass per comparison.
*
* **A dimension mismatch is fatal and loud.** A vector of the wrong width
* cannot be compared with the others, and silently storing one would corrupt
* the index in a way that looks like bad ranking rather than a broken config —
* the hardest possible failure to diagnose from the outside.
*/
/** Failure carrying a stable code, so the tool can explain rather than dump a stack. */
export class EmbeddingError extends Error {
/**
* @param {string} message human-readable cause
* @param {string} code stable identifier
*/
constructor(message, code) {
super(message)
this.name = 'EmbeddingError'
this.code = code
}
}
/** Characters sent per chunk. Comfortably inside an 8k-token code model's window. */
const MAX_INPUT_CHARS = 24_000
/** Attempts per request, including the first. */
const MAX_ATTEMPTS = 3
/** Base backoff between attempts. */
const RETRY_BASE_MS = 250
/** Truncate one input to the character budget. */
export function clampInput(text) {
return text.length <= MAX_INPUT_CHARS ? text : text.slice(0, MAX_INPUT_CHARS)
}
/** Scale a vector to unit length. A zero vector is returned unchanged. */
export function normalize(vector) {
let sum = 0
for (let at = 0; at < vector.length; at += 1) sum += vector[at] * vector[at]
if (sum === 0) return vector
const scale = 1 / Math.sqrt(sum)
for (let at = 0; at < vector.length; at += 1) vector[at] *= scale
return vector
}
/** Dot product, which is cosine similarity for unit vectors. */
export function dot(left, right) {
let sum = 0
const length = Math.min(left.length, right.length)
for (let at = 0; at < length; at += 1) sum += left[at] * right[at]
return sum
}
/** Sleep that rejects promptly when the caller's signal fires. */
function delay(ms, signal) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
signal?.removeEventListener('abort', onAbort)
resolve()
}, ms)
const onAbort = () => {
clearTimeout(timer)
reject(signal.reason ?? new Error('aborted'))
}
if (signal?.aborted) onAbort()
else signal?.addEventListener('abort', onAbort, { once: true })
})
}
/**
* Build an embedder bound to one endpoint.
*
* @param {object} options
* @param {string} options.apiBase base URL, without a trailing slash
* @param {string} options.model model id as the endpoint knows it
* @param {number} options.dimensions expected vector width
* @param {string} [options.apiKey] bearer token, when the endpoint wants one
* @param {number} [options.batchSize] inputs per request on the batch endpoint
* @param {number} [options.concurrency] requests in flight
* @param {number} [options.timeoutMs] per-request timeout
* @param {typeof fetch} [options.fetchImpl] injection point for tests
*/
export function createEmbedder(options) {
const {
apiBase,
model,
dimensions,
apiKey = '',
batchSize = 16,
concurrency = 2,
timeoutMs = 30_000,
maxAttempts = MAX_ATTEMPTS,
keepAlive = '',
fetchImpl = fetch,
} = options
const base = apiBase.replace(/\/+$/, '')
const headers = { 'content-type': 'application/json' }
if (apiKey !== '') headers.authorization = `Bearer ${apiKey}`
/**
* Add Ollama's `keep_alive` to a request body, when one is configured.
*
* Two reasons. An index run issues hundreds of requests, and without a
* keep-alive the model can be evicted between them and reloaded from disk
* repeatedly. And on a host already holding another model resident, a load
* that has to wait for memory is exactly the situation where a bounded,
* explicit residency beats Ollama's own default.
*/
const withKeepAlive = (body) => (keepAlive === '' ? body : { ...body, keep_alive: keepAlive })
// Discovered on first use: the batch endpoint is preferred, but an older
// Ollama build answers it with a 404 and then every request pays that
// discovery again. Remembering the answer keeps the fallback cheap.
let batchEndpointWorks = true
/** One HTTP request, with retries on transport faults and 5xx. */
async function request(path, body, signal) {
let lastError
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
if (signal?.aborted) throw signal.reason ?? new Error('aborted')
const timeout = AbortSignal.timeout(timeoutMs)
const combined = signal === undefined ? timeout : AbortSignal.any([signal, timeout])
try {
const response = await fetchImpl(`${base}${path}`, {
method: 'POST',
headers,
body: JSON.stringify(body),
signal: combined,
})
if (response.status === 404 || response.status === 405) {
const error = new EmbeddingError(`${path} is not available on ${base}`, 'ENDPOINT_UNSUPPORTED')
error.status = response.status
throw error
}
if (!response.ok) {
const text = await response.text().catch(() => '')
const error = new EmbeddingError(
`${path} responded ${response.status}${text === '' ? '' : `: ${text.slice(0, 300)}`}`,
'HTTP_ERROR',
)
error.status = response.status
throw error
}
return await response.json()
} catch (error) {
// A 4xx that is not a retryable status is a configuration fault, not a
// transient one, and retrying it just delays the explanation.
if (error instanceof EmbeddingError && error.status !== undefined && error.status < 500) throw error
if (signal?.aborted) throw signal.reason ?? error
lastError = error
if (attempt + 1 < MAX_ATTEMPTS) await delay(RETRY_BASE_MS * 2 ** attempt, signal)
}
}
throw new EmbeddingError(
`could not reach ${base}: ${lastError?.message ?? lastError}`,
'UNREACHABLE',
)
}
/** Check a returned vector's width against the configured one. */
function check(vector, index) {
if (!Array.isArray(vector)) {
throw new EmbeddingError(`response item ${index} was not an array`, 'BAD_RESPONSE')
}
if (dimensions > 0 && vector.length !== dimensions) {
throw new EmbeddingError(
`model "${model}" returned ${vector.length}-dimensional vectors but ${dimensions} are configured`,
'DIMENSION_MISMATCH',
)
}
return normalize(Float32Array.from(vector))
}
/** Embed one batch through whichever endpoint answers. */
async function embedBatch(texts, signal) {
const inputs = texts.map(clampInput)
if (inputs.length === 0) return []
if (batchEndpointWorks) {
try {
const payload = await request('/api/embed', withKeepAlive({ model, input: inputs }), signal)
if (!Array.isArray(payload?.embeddings) || payload.embeddings.length !== inputs.length) {
throw new EmbeddingError(
`/api/embed returned ${payload?.embeddings?.length ?? 0} vectors for ${inputs.length} inputs`,
'BAD_RESPONSE',
)
}
return payload.embeddings.map((vector, index) => check(vector, index))
} catch (error) {
// Only a missing endpoint falls back. A dimension mismatch or a bad
// response is a real fault and must not be papered over by a second
// endpoint that would fail the same way more slowly.
if (!(error instanceof EmbeddingError) || error.code !== 'ENDPOINT_UNSUPPORTED') throw error
batchEndpointWorks = false
}
}
const out = []
for (const input of inputs) {
const payload = await request('/api/embeddings', withKeepAlive({ model, prompt: input }), signal)
out.push(check(payload?.embedding, out.length))
}
return out
}
return {
model,
dimensions,
base,
/** Whether the batch endpoint has been ruled out. */
get batchEnabled() {
return batchEndpointWorks
},
/**
* Embed many texts, preserving order.
*
* @param {string[]} texts inputs
* @param {AbortSignal} [signal] caller cancellation
* @param {(done: number, total: number) => void} [onProgress]
* @returns {Promise<Float32Array[]>} unit vectors in input order
*/
async embed(texts, signal, onProgress) {
const out = new Array(texts.length)
const size = Math.max(1, Math.min(batchSize, texts.length))
const batches = []
for (let at = 0; at < texts.length; at += size) {
batches.push({ start: at, items: texts.slice(at, at + size) })
}
let done = 0
let cursor = 0
const workers = new Array(Math.max(1, Math.min(concurrency, batches.length))).fill(0).map(async () => {
while (cursor < batches.length) {
if (signal?.aborted) throw signal.reason ?? new Error('aborted')
const batch = batches[cursor]
cursor += 1
const vectors = await embedBatch(batch.items, signal)
for (let at = 0; at < vectors.length; at += 1) out[batch.start + at] = vectors[at]
done += vectors.length
onProgress?.(done, texts.length)
}
})
await Promise.all(workers)
return out
},
/**
* Embed one query string.
*
* @param {string} text query
* @param {AbortSignal} [signal] caller cancellation
* @returns {Promise<Float32Array>} unit vector
*/
async embedOne(text, signal) {
const [vector] = await embedBatch([text], signal)
return vector
},
}
}