Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,60 @@ Keep the default when the additional local model download and query latency are
not worthwhile for your project. See the [local model comparison](benchmarks/2026-08-12-local-embedding-model-comparison.md)
for the methodology and measured results.

#### Batching Ollama embeddings

The indexer sends multiple embedding texts to Ollama in one request. This decreases
the number of HTTP requests and accelerates indexing against a remote Ollama host.
The `/api/embed` endpoint accepts an array of texts and returns one vector per
text. The indexer uses this endpoint for batches of two or more texts. A single-text
batch uses the legacy `/api/embeddings` endpoint. A chunk that the splitter divides
into multiple parts sends one text per part, so a batch can carry several parts of
the same chunk.

If the batched endpoint is not available, the indexer falls back to the legacy
per-text endpoint and remembers the result, so later batches skip the probe. If one
text exceeds the model context length, the indexer truncates and retries that text
by itself. If the batch response is malformed, the indexer retries each text by
itself so a bad batch response re-embeds each text cleanly. This is not in-run
per-text isolation: if a text then hard-fails per-text, the whole request batch
fails and is marked failed. The chunks in that batch recover on the next `index()`
run, where the recovery path re-embeds one text per request so a persistently-failing
text is isolated from healthy texts.

Control the batch size with the `embedding.batch` section:

```json
{
"embedding": {
"batch": {
"maxBatchItems": 32,
"maxBatchTokens": 65536
}
}
}
```

| Option | Ollama default | Purpose |
|---|---:|---|
| `maxBatchItems` | `16` | Maximum number of embedding texts in one request |
| `maxBatchTokens` | `65536` | Maximum total estimated tokens in one request |

Ollama encodes each text independently. The model context length applies to each
text and not to the batch total. Set `maxBatchTokens` to bound the request size and
the processing time. Set `maxBatchItems` to bound the number of texts (a chunk split
into multiple parts counts as one text per part). Both values are optional and must
be at least 1. When you omit a value, the indexer uses the Ollama default. These
knobs apply only to the Ollama provider; OpenAI, Google, and custom providers ignore
them and keep their existing request behavior.

The indexer runs up to five Ollama requests at the same time. Each request carries
up to `maxBatchItems` texts, so the worst case is five times `maxBatchItems` texts
in flight (80 texts at the default 16). A remote or memory-limited Ollama host can
run out of memory or exceed the 120-second request timeout when this number is too
high. Lower `maxBatchItems` for a small or remote host. The Ollama concurrency is
fixed at five and is not configurable; the custom provider exposes concurrency
through `customProvider.concurrency`.

### OpenAI and Google

Set the provider and corresponding environment credentials:
Expand Down
39 changes: 39 additions & 0 deletions src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,11 +146,29 @@ export interface CustomProviderConfig {
max_batch_size?: number;
}

export interface EmbeddingBatchConfig {
/** Max texts per embedding request. Default: provider-specific (ollama 16). */
maxBatchItems?: number;
/** Max total input tokens per embedding request. This is a request size/time guard,
* not a model context limit: ollama encodes each input independently, so the per-batch
* token sum is not bounded by the model context length. Default: provider-specific (ollama 65536).
* Raise together with maxBatchItems to pack more texts per request; lower it if a single
* request approaches the request timeout. */
maxBatchTokens?: number;
}

export interface EmbeddingConfig {
/** Embedding request batching options. Currently applied to the ollama provider. */
batch?: EmbeddingBatchConfig;
}

export interface CodebaseIndexConfig {
embeddingProvider: EmbeddingProvider | 'custom' | 'auto';
embeddingModel?: EmbeddingModelName;
/** Configuration for custom OpenAI-compatible embedding providers (required when embeddingProvider is 'custom') */
customProvider?: CustomProviderConfig;
/** Embedding request shape options (e.g. batch sizes). Currently applied to the ollama provider. */
embedding?: EmbeddingConfig;
scope: IndexScope;
indexing?: Partial<IndexingConfig>;
search?: Partial<SearchConfig>;
Expand All @@ -177,6 +195,7 @@ export type ParsedCodebaseIndexConfig = CodebaseIndexConfig & {
reranker?: RerankerConfig;
knowledgeBases: string[];
additionalInclude: string[];
embedding: EmbeddingConfig;
};

export function parseConfig(raw: unknown): ParsedCodebaseIndexConfig {
Expand Down Expand Up @@ -376,10 +395,30 @@ export function parseConfig(raw: unknown): ParsedCodebaseIndexConfig {
};
}

const rawEmbedding = (input.embedding && typeof input.embedding === "object" ? input.embedding : {}) as Record<string, unknown>;
const rawEmbeddingBatch = (rawEmbedding.batch && typeof rawEmbedding.batch === "object" ? rawEmbedding.batch : null) as Record<string, unknown> | null;
const embeddingMaxBatchItems = typeof rawEmbeddingBatch?.maxBatchItems === "number"
&& Number.isFinite(rawEmbeddingBatch.maxBatchItems)
? Math.max(1, Math.floor(rawEmbeddingBatch.maxBatchItems))
: undefined;
const embeddingMaxBatchTokens = typeof rawEmbeddingBatch?.maxBatchTokens === "number"
&& Number.isFinite(rawEmbeddingBatch.maxBatchTokens)
? Math.max(1, Math.floor(rawEmbeddingBatch.maxBatchTokens))
: undefined;
const embedding: EmbeddingConfig = (embeddingMaxBatchItems !== undefined || embeddingMaxBatchTokens !== undefined)
? {
batch: {
...(embeddingMaxBatchItems !== undefined ? { maxBatchItems: embeddingMaxBatchItems } : {}),
...(embeddingMaxBatchTokens !== undefined ? { maxBatchTokens: embeddingMaxBatchTokens } : {}),
},
}
: {};

return {
embeddingProvider,
embeddingModel,
customProvider,
embedding,
scope: isValidScope(scopeValue) ? scopeValue : "project",
include: includeValue ?? DEFAULT_INCLUDE,
exclude: excludeValue ?? DEFAULT_EXCLUDE,
Expand Down
146 changes: 144 additions & 2 deletions src/embeddings/providers/ollama.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ export class OllamaEmbeddingProvider extends BaseEmbeddingProvider<EmbeddingProv
private static readonly MIN_TRUNCATION_CHARS = 512;
private static readonly REQUEST_TIMEOUT_MS = 120_000;

// Set when /api/embed returns 404 so subsequent multi-text batches skip the
// batched endpoint and go straight to the legacy per-text path (one probe per
// old ollama install, not one probe per batch).
private batchEndpointUnavailable = false;

public constructor(
credentials: ProviderCredentials,
modelInfo: EmbeddingProviderModelInfo["ollama"]
Expand All @@ -33,6 +38,23 @@ export class OllamaEmbeddingProvider extends BaseEmbeddingProvider<EmbeddingProv
|| message.includes("context length exceeded");
}

// True for a 404 from the newer /api/embed endpoint, i.e. an ollama version that
// does not provide it. embedBatch uses this to fall back to the legacy per-text
// /api/embeddings path so old ollama installs do not regress.
private isBatchEndpointUnavailableError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return message.includes("Ollama /api/embed not available");
}

// True for a malformed /api/embed response (wrong vector count or a bad vector).
// embedBatch falls back to the per-text path on this so a bad batch response
// re-embeds each text cleanly. A text that then fails per-text is not isolated
// here; it is isolated on the recovery run, which re-embeds one text per request.
private isBatchValidationError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return message.includes("invalid embedding batch");
}

private buildTruncationCandidates(text: string): string[] {
const baseMaxChars = Math.max(1, this.modelInfo.maxTokens * 4);
const candidateLimits = new Set<number>();
Expand Down Expand Up @@ -155,9 +177,89 @@ export class OllamaEmbeddingProvider extends BaseEmbeddingProvider<EmbeddingProv
};
}

public async embedBatch(texts: string[]): Promise<EmbeddingBatchResult> {
const results: Array<{ embedding: number[]; tokensUsed: number }> = [];
// Embeds many texts in one POST /api/embed request (input: string[]). Ollama
// encodes each input independently, so the model context length applies per input
// (the upstream splitter already bounds each input), not over the batch. This
// amortizes N HTTP round-trips into one.
private async embedMany(texts: string[]): Promise<EmbeddingBatchResult> {
const controller = new AbortController();
const timeout = setTimeout(
() => controller.abort(),
OllamaEmbeddingProvider.REQUEST_TIMEOUT_MS,
);
let response: Response;
try {
response = await fetch(`${this.credentials.baseUrl}/api/embed`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
model: this.modelInfo.model,
input: texts,
truncate: false,
}),
signal: controller.signal,
});
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
throw new Error(
`Ollama embedding request timed out after ${OllamaEmbeddingProvider.REQUEST_TIMEOUT_MS}ms`,
);
}
throw error;
} finally {
clearTimeout(timeout);
}

if (!response.ok) {
const error = (await response.text()).slice(0, 500);
if (response.status === 404) {
throw new Error(`Ollama /api/embed not available: ${response.status} - ${error}`);
}
throw new Error(`Ollama embedding API error: ${response.status} - ${error}`);
}

let parsed: unknown;
try {
parsed = await response.json();
} catch {
// Invalid JSON (e.g. an empty or truncated 200 body) -> treat as a malformed
// batch so embedBatch falls back to the per-text path instead of propagating
// a parse error that skips the fallback.
throw new Error(
`Ollama returned an invalid embedding batch; expected ${texts.length} vectors of ${this.modelInfo.dimensions} finite dimensions`,
);
}
const data = (parsed && typeof parsed === "object" ? parsed : {}) as { embeddings?: unknown };
if (
!Array.isArray(data.embeddings)
|| data.embeddings.length !== texts.length
|| data.embeddings.some(
(value) =>
!Array.isArray(value)
|| value.length !== this.modelInfo.dimensions
|| value.some((v) => typeof v !== "number" || !Number.isFinite(v)),
)
) {
throw new Error(
`Ollama returned an invalid embedding batch; expected ${texts.length} vectors of ${this.modelInfo.dimensions} finite dimensions`,
);
}

return {
embeddings: data.embeddings,
totalTokensUsed: texts.reduce((sum, text) => sum + this.estimateTokens(text), 0),
};
}

// Per-text /api/embeddings path shared by the single-text fast path and the
// batch fallback. Uses the legacy endpoint one text at a time, so each text gets
// its own truncation safety net and a vector validated on its own. A text that
// hard-fails per-text throws here and fails the whole request batch; the recovery
// run re-embeds one text per request to isolate it.
private async embedOneByOne(texts: string[]): Promise<EmbeddingBatchResult> {
const results: Array<{ embedding: number[]; tokensUsed: number }> = [];
for (const text of texts) {
results.push(await this.embedSingleWithFallback(text));
}
Expand All @@ -167,4 +269,44 @@ export class OllamaEmbeddingProvider extends BaseEmbeddingProvider<EmbeddingProv
totalTokensUsed: results.reduce((sum, r) => sum + r.tokensUsed, 0),
};
}

public async embedBatch(texts: string[]): Promise<EmbeddingBatchResult> {
if (texts.length === 0) {
return { embeddings: [], totalTokensUsed: 0 };
}

// A single-text batch gets no batching benefit; send it to the legacy per-text
// path directly so its truncation/timeout/error behavior stays unchanged and no
// /api/embed probe runs. Once /api/embed is known unavailable, multi-text batches
// also skip the probe (see batchEndpointUnavailable).
if (texts.length === 1 || this.batchEndpointUnavailable) {
return this.embedOneByOne(texts);
}

try {
return await this.embedMany(texts);
} catch (error) {
// Fall back to the per-text /api/embeddings path when the batched endpoint is
// unavailable (old ollama, 404), a single input overflowed the model context
// length (per-text truncation net), or the batch response was malformed (so a
// bad batch response re-embeds each text cleanly). This is not in-run per-text
// isolation: a text that hard-fails per-text throws and fails the whole request
// batch, and is isolated only on the recovery run, which re-embeds one text per
// request. Other errors propagate for the indexer's pRetry to handle.
if (this.isBatchEndpointUnavailableError(error)) {
// Old ollama without /api/embed: cache the result so later batches skip the probe.
this.batchEndpointUnavailable = true;
return this.embedOneByOne(texts);
}

if (
!this.isContextLengthError(error)
&& !this.isBatchValidationError(error)
) {
throw error;
}

return this.embedOneByOne(texts);
}
}
}
52 changes: 39 additions & 13 deletions src/indexer/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { promisify } from "util";
import PQueue from "p-queue";
import pRetry from "p-retry";

import { ParsedCodebaseIndexConfig, type RerankerConfig } from "../config/schema.js";
import { type EmbeddingBatchConfig, ParsedCodebaseIndexConfig, type RerankerConfig } from "../config/schema.js";
import { detectEmbeddingProvider, ConfiguredProviderInfo, tryDetectProvider, createCustomProviderInfo } from "../embeddings/detector.js";
import {
createEmbeddingProvider,
Expand Down Expand Up @@ -305,15 +305,31 @@ function getSafeEmbeddingChunkTokenLimit(provider: ConfiguredProviderInfo): numb
return Math.min(2000, maxChunkTokens);
}

function getDynamicBatchOptions(provider: ConfiguredProviderInfo): { maxBatchTokens?: number; maxBatchItems?: number } {
if (provider.provider === "ollama") {
return {
maxBatchTokens: provider.modelInfo.maxTokens,
maxBatchItems: 1,
};
// Ollama default batch caps. maxBatchItems is the count limiter; maxBatchTokens is a
// request size/time guard (ollama encodes each input independently, so the per-batch
// token sum is not bounded by the model context). 65536 allows the default 16 items
// (each input is split to <= ~1536 tokens) and is overridable via embedding.batch.
// 16 (not 32) bounds the in-flight workload: ollama concurrency is fixed at 5, so the
// worst case is 5 concurrent batches * 16 inputs (~80 texts) rather than 160.
const DEFAULT_OLLAMA_MAX_BATCH_ITEMS = 16;
const DEFAULT_OLLAMA_MAX_BATCH_TOKENS = 65_536;

export function getDynamicBatchOptions(
provider: ConfiguredProviderInfo,
embeddingBatch?: EmbeddingBatchConfig,
): { maxBatchTokens?: number; maxBatchItems?: number } {
// embedding.batch.* is documented as ollama-only. Non-ollama providers keep
// their existing (unbatched-by-this-layer) behavior, so return an empty
// options object regardless of any user-supplied batch config.
if (provider.provider !== "ollama") {
return {};
}

return {};
const base = { maxBatchTokens: DEFAULT_OLLAMA_MAX_BATCH_TOKENS, maxBatchItems: DEFAULT_OLLAMA_MAX_BATCH_ITEMS };
return {
...base,
...(typeof embeddingBatch?.maxBatchTokens === "number" && Number.isFinite(embeddingBatch.maxBatchTokens) ? { maxBatchTokens: embeddingBatch.maxBatchTokens } : {}),
...(typeof embeddingBatch?.maxBatchItems === "number" && Number.isFinite(embeddingBatch.maxBatchItems) ? { maxBatchItems: embeddingBatch.maxBatchItems } : {}),
};
}

function isSqliteCorruptionError(error: unknown): boolean {
Expand Down Expand Up @@ -2522,6 +2538,9 @@ export class Indexer {
forceReembed: boolean;
reuseCachedEmbeddings: boolean;
incrementRepeatedFailures: boolean;
// When true (recovery path), embed previously-failed chunks one per request
// so a permanently-failing chunk is isolated instead of failing its batch.
forceSingleItemBatches?: boolean;
onSucceeded?: (chunks: PendingChunk[]) => void;
onProgress?: (progress: Readonly<PendingChunkBatchResult>) => void;
},
Expand Down Expand Up @@ -2580,10 +2599,15 @@ export class Indexer {
const embeddingPartsByChunk = new Map<string, Array<{ vector: number[]; tokenCount: number } | undefined>>();
const completedVectorsByChunkId = new Map<string, number[]>();
const completedChunkIds = new Set<string>();
const requestBatches = createPendingEmbeddingRequestBatches(
chunksNeedingEmbedding,
getDynamicBatchOptions(options.configuredProviderInfo),
);
const batchOptions = getDynamicBatchOptions(options.configuredProviderInfo, this.config.embedding?.batch);
// On the recovery path, embed previously-failed chunks one per request so a
// permanently-failing chunk is isolated instead of failing its whole batch.
// Scoped to ollama (whose default batch size groups chunks); other providers
// keep their existing recovery batching.
if (options.forceSingleItemBatches && options.configuredProviderInfo.provider === "ollama") {
batchOptions.maxBatchItems = 1;
}
const requestBatches = createPendingEmbeddingRequestBatches(chunksNeedingEmbedding, batchOptions);
let fatalError: unknown;

for (const requestBatch of requestBatches) {
Expand Down Expand Up @@ -4662,6 +4686,7 @@ export class Indexer {
forceReembed: forceScopedReembed,
reuseCachedEmbeddings: true,
incrementRepeatedFailures: true,
forceSingleItemBatches: true,
onSucceeded: (succeededChunks) => {
database.addChunksToBranchBatch(
this.getBranchCatalogKey(),
Expand Down Expand Up @@ -5898,6 +5923,7 @@ export class Indexer {
forceReembed: false,
reuseCachedEmbeddings: false,
incrementRepeatedFailures: false,
forceSingleItemBatches: true,
onSucceeded: (succeededChunks) => {
database.addChunksToBranchBatch(
this.getBranchCatalogKey(),
Expand Down
Loading