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
12 changes: 10 additions & 2 deletions src/lib/modelMetadataNormalizer.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { lookupModelInfoSync, type ModelsDevApiResponse } from "../providers/modelsDevApi";
import { formatParameterSize, type OllamaModelInfo } from "../providers/ollamaModels";
import { formatParameterSize, OLLAMA_EMBED_NUM_CTX, type OllamaModelInfo } from "../providers/ollamaModels";
import {
extractCapabilities as extractOpenRouterCapabilities,
type OpenRouterModelInfo,
Expand Down Expand Up @@ -165,14 +165,22 @@ export function hydrateEmbeddingModel(

const providerDefaultMaxInputTokens = DEFAULT_EMBED_MAX_INPUT_TOKENS_BY_PROVIDER[provider];

const maxInputTokens =
const advertisedMaxInputTokens =
maxInputFromOpenRouter ||
ollama?.contextLength ||
modelsDev?.limit?.input ||
modelsDev?.limit?.context ||
providerDefaultMaxInputTokens ||
DEFAULT_EMBEDDING_MAX_INPUT_TOKENS;

// Ollama's `/api/show` reports the architecture's context length (40960 for
// qwen3-embedding), not what the server will accept: that is bounded by the
// `num_ctx` each request carries. The embedding client pins num_ctx to
// OLLAMA_EMBED_NUM_CTX, so the budget the chunker sizes against must be the
// same number or every long note is rejected (#485).
const maxInputTokens =
provider === "ollama" ? Math.min(advertisedMaxInputTokens, OLLAMA_EMBED_NUM_CTX) : advertisedMaxInputTokens;
Comment thread
greptile-apps[bot] marked this conversation as resolved.

const inputUsdPer1M = toUsdPer1MFromPerToken(openRouter?.pricing?.prompt);

return {
Expand Down
29 changes: 29 additions & 0 deletions src/providers/chatProviders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -408,11 +408,40 @@ export function createTransportedOpenAIEmbeddings(
});
}

/**
* Retry policy for Ollama embedding calls.
*
* LangChain's default handler skips retries for 4xx responses, but it reads
* the status from `status` / `statusCode` / `response.status`, and the Ollama
* client's `ResponseError` carries it as `status_code`. So a permanent
* rejection — "the input length exceeds the context length", HTTP 400 — was
* retried six times with exponential backoff: 7 requests and ~85 s of idle GPU
* per call, and the bulk indexer then repeats that for every entry of the
* failed batch (#485). Replacing the handler drops the default's other rules,
* so the one that matters is restated here: a cancellation is never retried.
* Everything else (transport failures, 5xx) keeps the default retry budget.
*/
export function ollamaEmbedFailedAttempt(error: unknown): void {
if (typeof error !== "object" || error === null) return;
const {
name,
message,
status_code: statusCode,
} = error as {
name?: unknown;
message?: unknown;
status_code?: unknown;
};
if (name === "AbortError" || (typeof message === "string" && message.startsWith("Cancel"))) throw error;
if (typeof statusCode === "number" && statusCode >= 400 && statusCode < 500) throw error;
}

export function createTransportedOllamaEmbeddings(
config: ConstructorParameters<typeof OllamaEmbeddings>[0],
): OllamaEmbeddings {
const baseConfig = config ?? {};
return new OllamaEmbeddings({
onFailedAttempt: ollamaEmbedFailedAttempt,
...baseConfig,
fetch: baseConfig.fetch ?? createObsidianFetch(),
});
Expand Down
11 changes: 10 additions & 1 deletion src/providers/ollama.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import type {
ChatModelConfig,
EmbeddingProviderDefinition,
} from "../types/provider/index";
import { fetchOllamaModelsInfo } from "./ollamaModels";
import { fetchOllamaModelsInfo, OLLAMA_EMBED_NUM_CTX } from "./ollamaModels";
import { createTransportedChatOllama, createTransportedOllamaEmbeddings } from "./chatProviders";

// =============================================================================
Expand Down Expand Up @@ -144,6 +144,15 @@ export const ollamaProvider: EmbeddingProviderDefinition = {
return createTransportedOllamaEmbeddings({
model: modelId,
baseUrl: sanitizeBaseUrl(auth.baseUrl),
// The server bounds embed input by min(num_ctx, model context), and
// its num_ctx default is a VRAM-dependent 4k+ the client cannot read.
// Pin it, so the chunk budget (`hydrateEmbeddingModel` caps Ollama
// models to the same number) is the limit the server enforces (#485).
requestOptions: { numCtx: OLLAMA_EMBED_NUM_CTX },
// Backstop for chunks denser than the chars-per-token estimate (JSON,
// code): a truncated vector still finds the note by its opening,
// whereas a rejected one drops the note from the index entirely.
truncate: true,
});
},

Expand Down
13 changes: 13 additions & 0 deletions src/providers/ollamaModels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,19 @@ import { Logger } from "../utils/logging";

const CACHE_TTL_MS = 1000 * 60 * 60; // 1 hour (shorter than cloud APIs since local)

/**
* `num_ctx` sent with every Ollama embedding request, and the ceiling on the
* input budget the chunker sizes Ollama models against.
*
* The server accepts min(num_ctx, model context) tokens per input, and its
* num_ctx default is chosen from free VRAM (4k and up) — not readable through
* the API, and unrelated to the architecture context `/api/show` reports.
* Pinning one value on both sides is what makes a chunk that fits the budget
* fit the server (#485). 8k covers every current embedding model's useful
* range while keeping the KV allocation modest on small GPUs.
*/
export const OLLAMA_EMBED_NUM_CTX = 8192;

/**
* Model details from Ollama API
*/
Expand Down
41 changes: 33 additions & 8 deletions src/vectorstore/VectorStoreService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,8 +140,16 @@ const DEFAULT_EMBED_MAX_INPUT_TOKENS = 8191;
/** Debounce delay before re-embedding a modified file (ms) */
const MODIFY_DEBOUNCE_MS = 5_000;

/** Approximate chars per token for rough estimation */
const CHARS_PER_TOKEN = 4;
/**
* Chars per token for sizing chunks against a model's input cap.
*
* Four is the usual English-prose figure, but a chunk budget has to hold for
* the densest content a note can contain — JSON, code, tables and non-Latin
* scripts tokenize at 2–3 chars per token — and an overshoot is a rejected (or
* silently truncated) chunk. Estimating low costs a few more, smaller chunks
* on prose; estimating high dropped whole notes from the index (#485).
*/
const CHARS_PER_TOKEN = 3;

/**
* Over-fetch factor for semantic search. A note is stored as one vector per
Expand Down Expand Up @@ -1436,13 +1444,28 @@ export class VectorStoreService {
if (entry.chunkIndex === 0) noteIndexed(entry.file.path);
};

// A note that lost any chunk must not get its chunk 0 written. Chunk 0 is
// the "note is indexed" marker (`orderChunksForWriting` puts it last), so
// writing it after a sibling failed would leave a partial note that startup
// validation reads as complete — its sections silently missing from
// retrieval for as long as the note's mtime holds. Dropping the note's
// remaining chunks instead leaves it without `#0`, and the next validation
// pass re-indexes it whole.
const failedPaths = new Set<string>();
const noteFailed = (path: string, reason: SkipReason) => {
failedPaths.add(path);
noteSkipped(path, reason);
};

// Consecutive transport-level failures. Reset on any success, so a flaky
// connection that recovers does not accumulate toward the limit.
let consecutiveUnreachable = 0;
let batchNumber = 0;

/** Embed and store one batch. Resolves false when the run must stop. */
const embedBatch = async (batch: ChunkEntry[]): Promise<boolean> => {
const embedBatch = async (queued: ChunkEntry[]): Promise<boolean> => {
const batch = queued.filter((entry) => !failedPaths.has(entry.file.path));
if (batch.length === 0) return true;
batchNumber++;
this.updateInstanceProgress(inst, {
currentFile: batch.length === 1 ? batch[0].file.path : `Embedding batch ${batchNumber}...`,
Expand All @@ -1460,13 +1483,14 @@ export class VectorStoreService {
consecutiveUnreachable = 0;
if (!vectors || vectors.length === 0) {
Logger.error(`[VectorStore] embedDocuments returned empty result for ${inst.indexId}`);
for (const entry of batch) noteSkipped(entry.file.path, "embed-error");
for (const entry of batch) noteFailed(entry.file.path, "embed-error");
return true;
}
for (let j = 0; j < batch.length; j++) {
if (failedPaths.has(batch[j].file.path)) continue;
if (!vectors[j]) {
Logger.error(`[VectorStore] Empty vector for ${batch[j].file.path}`);
noteSkipped(batch[j].file.path, "embed-error");
noteFailed(batch[j].file.path, "embed-error");
continue;
}
await writeVector(batch[j], vectors[j]);
Expand All @@ -1487,18 +1511,19 @@ export class VectorStoreService {
// Account for the batch before skipping the per-entry retry, so
// the notes are reported as skipped rather than silently dropped
// from a run that still claims success.
for (const entry of batch) noteSkipped(entry.file.path, "embed-error");
for (const entry of batch) noteFailed(entry.file.path, "embed-error");
return true;
}
consecutiveUnreachable = 0;

for (const entry of batch) {
if (aborted()) return false;
if (failedPaths.has(entry.file.path)) continue;
try {
const vector = await this.embedWithCancellation(inst, embeddings.embedQuery(entry.embedText));
if (!vector || vector.length === 0) {
Logger.error(`[VectorStore] embedQuery returned empty result for ${entry.file.path}`);
noteSkipped(entry.file.path, "embed-error");
noteFailed(entry.file.path, "embed-error");
continue;
}
await writeVector(entry, vector);
Expand All @@ -1510,7 +1535,7 @@ export class VectorStoreService {
Logger.error(`[VectorStore] Failed to index ${entry.file.path}:`, entryError);
const reason = entryError instanceof Error ? entryError.message : String(entryError);
new Notice(`Failed to embed ${entry.file.basename}: ${reason}`);
noteSkipped(entry.file.path, "embed-error");
noteFailed(entry.file.path, "embed-error");
}
}
return true;
Expand Down
40 changes: 40 additions & 0 deletions test/lib/modelMetadataNormalizer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { hydrateEmbeddingModel } from "../../src/lib/modelMetadataNormalizer";
import { OLLAMA_EMBED_NUM_CTX, type OllamaModelInfo } from "../../src/providers/ollamaModels";

function ollamaData(name: string, contextLength: number | undefined): Map<string, OllamaModelInfo> {
return new Map([[name, { name, contextLength, capabilities: [], supportsVision: false, supportsTools: false }]]);
}

describe("hydrateEmbeddingModel — Ollama input budget", () => {
it("caps the advertised architecture context to the num_ctx the client sends", () => {
// qwen3-embedding reports 40960 via /api/show; the server enforces num_ctx (#485).
const model = hydrateEmbeddingModel("ollama", "qwen3-embedding:4b", {
ollamaData: ollamaData("qwen3-embedding:4b", 40960),
});
expect(model.maxInputTokens).toBe(OLLAMA_EMBED_NUM_CTX);
});

it("keeps a smaller model context as-is", () => {
const model = hydrateEmbeddingModel("ollama", "nomic-embed-text", {
ollamaData: ollamaData("nomic-embed-text", 2048),
});
expect(model.maxInputTokens).toBe(2048);
});

it("caps the provider default too when /api/show has no context length", () => {
const model = hydrateEmbeddingModel("ollama", "unknown-embed", {
ollamaData: ollamaData("unknown-embed", undefined),
});
expect(model.maxInputTokens).toBeLessThanOrEqual(OLLAMA_EMBED_NUM_CTX);
});

it("leaves other providers uncapped", () => {
const model = hydrateEmbeddingModel("openrouter", "some/embed", {
openRouterData: new Map([
["some/embed", { id: "some/embed", name: "Embed", context_length: 32768 } as never],
]),
});
expect(model.maxInputTokens).toBe(32768);
});
});
68 changes: 68 additions & 0 deletions test/providers/ollamaEmbeddings.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/**
* Ollama embedding transport: retry policy and request shape.
*
* Regression coverage for #485 — a permanent HTTP 400 ("the input length
* exceeds the context length") was retried six times with exponential backoff
* because the Ollama client reports the status as `status_code`, which
* LangChain's default retry handler does not read.
*/

import { describe, expect, it, vi } from "vitest";
import { createTransportedOllamaEmbeddings, ollamaEmbedFailedAttempt } from "../../src/providers/chatProviders";
import { ollamaProvider } from "../../src/providers/ollama";
import { OLLAMA_EMBED_NUM_CTX } from "../../src/providers/ollamaModels";

function ollamaError(status_code: number, message = "the input length exceeds the context length") {
const error = new Error(message);
error.name = "ResponseError";
return Object.assign(error, { error: message, status_code });
}

describe("ollamaEmbedFailedAttempt", () => {
it("stops retrying on a 4xx Ollama ResponseError", () => {
const error = ollamaError(400);
expect(() => ollamaEmbedFailedAttempt(error)).toThrow(error);
expect(() => ollamaEmbedFailedAttempt(ollamaError(404, "model not found"))).toThrow();
});

it("keeps retrying on 5xx and transport failures", () => {
expect(() => ollamaEmbedFailedAttempt(ollamaError(500, "model runner has unexpectedly stopped"))).not.toThrow();
expect(() => ollamaEmbedFailedAttempt(new TypeError("fetch failed"))).not.toThrow();
expect(() => ollamaEmbedFailedAttempt(undefined)).not.toThrow();
});

it("never retries a cancellation", () => {
expect(() => ollamaEmbedFailedAttempt(new DOMException("Indexing cancelled", "AbortError"))).toThrow();
expect(() => ollamaEmbedFailedAttempt(new Error("Cancel: aborted"))).toThrow();
});
});

describe("createTransportedOllamaEmbeddings", () => {
it("issues exactly one request when the server rejects the input with 400", async () => {
const fetchMock = vi.fn(
async () =>
new Response(JSON.stringify({ error: "the input length exceeds the context length" }), {
status: 400,
headers: { "content-type": "application/json" },
}),
);
const embeddings = createTransportedOllamaEmbeddings({
model: "qwen3-embedding:4b",
baseUrl: "http://localhost:11434",
fetch: fetchMock as unknown as typeof fetch,
});

await expect(embeddings.embedDocuments(["a very long chunk"])).rejects.toThrow(/exceeds the context length/);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});

describe("ollamaProvider.createEmbeddingInstance", () => {
it("pins num_ctx to the chunk budget and truncates as a backstop", () => {
const embeddings = ollamaProvider.createEmbeddingInstance({ baseUrl: "http://localhost:11434/" }, "m");
expect(embeddings).toMatchObject({
truncate: true,
requestOptions: { num_ctx: OLLAMA_EMBED_NUM_CTX },
});
});
});
Loading