Skip to content
Open
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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ The unreleased source candidate starts new indexes without a model download. Sem
- Better React/Next.js detection and a fix for reranker passage scoring.
- Transformers.js v4 for local models.

Known limits: [Intel Mac indexing crash (#68)](https://github.com/PatrickSys/codebase-context/issues/68), [custom OpenAI endpoints (#70)](https://github.com/PatrickSys/codebase-context/issues/70) and [Ollama (#71)](https://github.com/PatrickSys/codebase-context/issues/71) remain open.
Known limits: [Intel Mac indexing crash (#68)](https://github.com/PatrickSys/codebase-context/issues/68) and [custom OpenAI endpoints (#70)](https://github.com/PatrickSys/codebase-context/issues/70) remain open on published npm; master adds native Ollama and `OPENAI_BASE_URL` wiring.

The existing [benchmark](https://github.com/PatrickSys/codebase-context/blob/master/docs/benchmark.md) reports 25.7% expected-file recovery versus jCodeMunch's 27.1% in a fixed retrieval comparison; it does not establish a winner or coding quality.

Expand Down Expand Up @@ -132,7 +132,15 @@ More examples are available in the [CLI gallery](./docs/cli.md) and [walkthrough

Code and indexes stay on the machine with the default local embedding provider. Docker, a GPU, and an API key are not required.

Cloud embeddings are optional. If you select a cloud provider, code chunks are sent to that provider to create the search index. The provider, model, project root, and local HTTP port can be changed through environment variables or the project config; see the [capabilities reference](./docs/capabilities.md).
For local embeddings through [Ollama](https://ollama.com) (avoids Transformers.js on affected Macs, see #68), enable semantic indexing and set:

```bash
EMBEDDING_PROVIDER=ollama
EMBEDDING_MODEL=nomic-embed-text # optional; default when unset
OLLAMA_HOST=http://localhost:11434 # optional
```

Cloud embeddings are optional. If you select OpenAI (`EMBEDDING_PROVIDER=openai`), code chunks are sent to that provider to create the search index. Override the API base with `OPENAI_BASE_URL` (compatible with Ollama’s OpenAI layer, but native `ollama` provider is preferred). The provider, model, project root, and local HTTP port can be changed through environment variables or the project config; see the [capabilities reference](./docs/capabilities.md).

This is the privacy boundary of Codebase Context itself. Your AI client may send search results or file contents to the model provider configured in that client. Codebase Context does not control that connection. If you commit and push `.codebase-context/memory.json`, the recorded project memory also travels with the repository.

Expand Down
4 changes: 2 additions & 2 deletions docs/capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ Ordered by execution:
- **Chunk size**: 50 lines, 0 overlap
- **Reranker trigger**: activates when top-3 results are within 0.08 score of each other
- **Embedding mode**: new indexes are model-free. Existing indexes keep their recorded mode unless explicitly changed. `reindex --embeddings` opts into semantic indexing and requires the optional model/storage packages.
- **Semantic model when enabled**: `Xenova/bge-small-en-v1.5` (512 token context, fast, local-first) via `@huggingface/transformers`. Override: `EMBEDDING_MODEL=onnx-community/granite-embedding-small-english-r2-ONNX` for Granite (8192 ctx, slower).
- **Semantic model when enabled**: `Xenova/bge-small-en-v1.5` (512 token context, fast, local-first) via `@huggingface/transformers`. Override: `EMBEDDING_MODEL=onnx-community/granite-embedding-small-english-r2-ONNX` for Granite (8192 ctx, slower). **Ollama**: `EMBEDDING_PROVIDER=ollama`, optional `OLLAMA_HOST` (default `http://localhost:11434`), default model `nomic-embed-text` — embeddings stay on your machine via Ollama’s `/api/embed`.
- **Vector DB**: LanceDB with cosine distance

## Decision Card (Edit Intent)
Expand Down Expand Up @@ -318,5 +318,5 @@ Reproducible evaluation is shipped as a CLI entrypoint backed by shared scoring
- **Symbol refs are not a call-graph.** `get_symbol_references` counts identifier-node occurrences in the AST (comments/strings excluded via Tree-sitter). It does not distinguish call sites from type annotations, variable assignments, or imports. Full call-site-specific analysis (`call_expression` nodes only) is a roadmap item.
- **Impact is 2-hop max.** `computeImpactCandidates` walks direct importers then their importers. Full BFS reachability is on the roadmap.
- **Planned for unpublished 2.4.0:** NestJS analyzer support, React and Next.js detection improvements, and reranker fixes. Dedicated Angular, React, and Next.js analyzers already ship in published 2.2.0. Other languages use the Generic analyzer for indexing and retrieval; available symbol extraction depends on parser support. File import resolution and multi-hop navigation currently support TypeScript and JavaScript. Python and Go import navigation are not supported.
- **The unreleased candidate defaults new indexes to model-free mode. Published 2.2.0 enables embeddings by default.** When semantic mode is enabled, its default model is `bge-small-en-v1.5` (512-token context). Granite (8192 context) is opt-in via `EMBEDDING_MODEL`. OpenAI is opt-in via `EMBEDDING_PROVIDER=openai` — sends code externally.
- **The unreleased candidate defaults new indexes to model-free mode. Published 2.2.0 enables embeddings by default.** When semantic mode is enabled, its default model is `bge-small-en-v1.5` (512-token context). Granite (8192 context) is opt-in via `EMBEDDING_MODEL`. OpenAI is opt-in via `EMBEDDING_PROVIDER=openai` (`OPENAI_BASE_URL` overrides the API host). Ollama is opt-in via `EMBEDDING_PROVIDER=ollama` — local HTTP only, no API key.
- **Patterns are file-level frequency counts.** Not semantic clustering. Rising/Declining trend is derived from git commit recency for files using each pattern, not from usage semantics.
4 changes: 3 additions & 1 deletion src/core/indexer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,9 @@ export class CodebaseIndexer {
const defaultModel =
defaultEmbeddingProvider === 'openai' && !process.env.EMBEDDING_MODEL
? 'text-embedding-3-small'
: DEFAULT_MODEL;
: defaultEmbeddingProvider === 'ollama' && !process.env.EMBEDDING_MODEL
? 'nomic-embed-text'
: DEFAULT_MODEL;

const defaultConfig: CodebaseConfig = {
analyzers: {
Expand Down
20 changes: 16 additions & 4 deletions src/embeddings/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,17 @@ import {
EmbeddingConfig,
DEFAULT_EMBEDDING_CONFIG,
DEFAULT_MODEL,
parseEmbeddingProviderName
DEFAULT_OLLAMA_MODEL,
parseEmbeddingProviderName,
resolveOllamaHost
} from './types.js';
import { TransformersEmbeddingProvider, MODEL_CONFIGS } from './transformers.js';
import { getOllamaModelDimensions } from './ollama.js';
import { assertOptionalDependency } from '../utils/optional-dependency.js';

export function assertEmbeddingRuntimeAvailable(config: Partial<EmbeddingConfig> = {}): void {
const provider = config.provider ?? DEFAULT_EMBEDDING_CONFIG.provider;
if (provider !== 'openai' && provider !== 'custom') {
if (provider !== 'openai' && provider !== 'custom' && provider !== 'ollama') {
assertOptionalDependency('@huggingface/transformers', '^4.2.0');
}
}
Expand All @@ -30,6 +33,7 @@ export function getConfiguredDimensions(config: Partial<EmbeddingConfig> = {}):
config.provider ?? parseEmbeddingProviderName(process.env.EMBEDDING_PROVIDER) ?? 'transformers';
const model = config.model ?? process.env.EMBEDDING_MODEL ?? DEFAULT_MODEL;
if (provider === 'openai') return model.includes('large') ? 3072 : 1536; // text-embedding-3-large: 3072, all others: 1536
if (provider === 'ollama') return getOllamaModelDimensions(model);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Custom models cannot refresh

getConfiguredDimensions keeps using a fixed guess after Ollama returns the actual vector size. If a custom model returns 1024 values and OLLAMA_EMBEDDING_DIMENSIONS is unset, a full build succeeds. A later incremental refresh compares those stored vectors against 768 and throws IndexCorruptedError.

Use the resolved vector size for the incremental check, or require an explicit size before building an unknown model.

Knowledge Base Used: Index storage and integrity

// Look up from the same MODEL_CONFIGS the provider uses — avoids stale hardcoded guesses
return MODEL_CONFIGS[model]?.dimensions ?? 384;
}
Expand Down Expand Up @@ -62,11 +66,19 @@ export async function getEmbeddingProvider(
}

if (mergedConfig.provider === 'custom') {
throw new Error("Custom provider not implemented. Use 'openai' or 'transformers'.");
throw new Error("Custom provider not implemented. Use 'openai', 'ollama', or 'transformers'.");
}

if (mergedConfig.provider === 'ollama') {
console.warn('Ollama provider not yet implemented, falling back to Transformers.js');
const { OllamaEmbeddingProvider } = await import('./ollama.js');
const provider = new OllamaEmbeddingProvider(
mergedConfig.model || DEFAULT_OLLAMA_MODEL,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Resolve the default model from the effective provider

When Ollama is selected through project configuration or getEmbeddingProvider({ provider: 'ollama' }), without setting EMBEDDING_PROVIDER, the merged configuration still contains the nonempty Transformers default Xenova/bge-small-en-v1.5. Consequently, this fallback never selects nomic-embed-text, and initialization requests the wrong model from Ollama, failing even when the documented default model is installed. Resolve provider-specific defaults after applying the explicit provider configuration; the indexer's configuration merge needs the same treatment.

Useful? React with 👍 / 👎.

mergedConfig.apiEndpoint ?? resolveOllamaHost()
);
await provider.initialize();
cachedProvider = provider;
cachedProviderType = providerKey;
return provider;
}

const provider = new TransformersEmbeddingProvider(mergedConfig.model);
Expand Down
155 changes: 155 additions & 0 deletions src/embeddings/ollama.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { EmbeddingProvider } from './types.js';

/** Common Ollama embedding models and their vector sizes (for LanceDB dimension checks). */
export const OLLAMA_MODEL_DIMENSIONS: Record<string, number> = {
'nomic-embed-text': 768,
'mxbai-embed-large': 1024,
'all-minilm': 384,
'snowflake-arctic-embed:137m': 768,
'snowflake-arctic-embed:335m': 1024
};

interface OllamaEmbedResponse {
embeddings: number[][];
}

interface OllamaShowResponse {
modelfile?: string;
parameters?: string;
}

function normalizeOllamaHost(host: string): string {
return host.replace(/\/+$/, '');
}

export function getOllamaModelDimensions(modelName: string): number {
const direct = OLLAMA_MODEL_DIMENSIONS[modelName];
if (direct !== undefined) {
return direct;
}
const baseName = modelName.split(':')[0] ?? modelName;
const fromBase = OLLAMA_MODEL_DIMENSIONS[baseName];
if (fromBase !== undefined) {
return fromBase;
}
const fromEnv = process.env.OLLAMA_EMBEDDING_DIMENSIONS;
if (fromEnv) {
const parsed = parseInt(fromEnv, 10);
if (!Number.isNaN(parsed) && parsed > 0) {
return parsed;
}
}
return 768;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use discovered dimensions for incremental validation

For an Ollama model outside the hardcoded map that returns vectors other than 768 dimensions, a full build succeeds because embedBatch() discovers the actual vector length. However, the next incremental build calls getConfiguredDimensions(), which independently returns this 768 fallback unless OLLAMA_EMBEDDING_DIMENSIONS is set. LanceDBStorageProvider.initialize() then rejects the valid existing table with a dimension-mismatch error. Use the discovered or persisted dimensions for incremental validation rather than treating an unknown model's guessed size as authoritative.

Useful? React with 👍 / 👎.

}

/**
* Ollama embedding provider — HTTP client for a local Ollama server (`/api/embed`).
*/
export class OllamaEmbeddingProvider implements EmbeddingProvider {
readonly name = 'ollama';
private resolvedDimensions: number;

constructor(
readonly modelName: string,
private host: string = 'http://localhost:11434'
) {
this.host = normalizeOllamaHost(host);
this.resolvedDimensions = getOllamaModelDimensions(modelName);
}

get dimensions(): number {
return this.resolvedDimensions;
}

async initialize(): Promise<void> {
let response: Response;
try {
response = await fetch(`${this.host}/api/show`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: this.modelName })
});
} catch (error) {
throw new Error(
`Cannot reach Ollama at ${this.host}. Start the server (ollama serve) or set OLLAMA_HOST. ${String(error)}`
);
}

if (response.status === 404) {
throw new Error(
`Ollama model "${this.modelName}" is not available. Pull it first: ollama pull ${this.modelName}`
);
}

if (!response.ok) {
const error = await response.text();
throw new Error(`Ollama API Error ${response.status}: ${error}`);
}

const show = (await response.json()) as OllamaShowResponse;
const fromModelfile = parseEmbeddingLengthFromShow(show);
if (fromModelfile !== undefined) {
this.resolvedDimensions = fromModelfile;
}
}

isReady(): boolean {
return true;
}

async embed(text: string): Promise<number[]> {
const batch = await this.embedBatch([text]);
return batch[0];
}

async embedBatch(texts: string[]): Promise<number[][]> {
if (!texts.length) return [];

try {
const response = await fetch(`${this.host}/api/embed`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: this.modelName,
input: texts.length === 1 ? texts[0] : texts
})
});
Comment on lines +109 to +116

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Stuck requests stall indexing

Neither new HTTP request has an application timeout. If Ollama stops answering while loading or running a model, indexing keeps waiting rather than reporting a useful failure.

Add a configurable deadline to both /api/show and /api/embed, with enough time for a cold model load. This is a non-blocking improvement.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!


if (!response.ok) {
const error = await response.text();
if (response.status === 404 && error.includes('not found')) {
throw new Error(
`Ollama model "${this.modelName}" is not available. Pull it first: ollama pull ${this.modelName}`
);
}
throw new Error(`Ollama API Error ${response.status}: ${error}`);
}

const data = (await response.json()) as OllamaEmbedResponse;
if (!Array.isArray(data.embeddings) || data.embeddings.length !== texts.length) {
throw new Error(
`Ollama returned ${data.embeddings?.length ?? 0} embeddings for ${texts.length} inputs`
);
}

if (data.embeddings[0]?.length && data.embeddings[0].length !== this.resolvedDimensions) {
this.resolvedDimensions = data.embeddings[0].length;
}

return data.embeddings;
} catch (error) {
console.error('Ollama Embedding Failed:', error);
throw error;
}
}
}

function parseEmbeddingLengthFromShow(show: OllamaShowResponse): number | undefined {
const haystack = `${show.modelfile ?? ''}\n${show.parameters ?? ''}`;
const match = haystack.match(/embedding[_\s-]?length[\s=:]+(\d+)/i);
if (!match) {
return undefined;
}
const parsed = parseInt(match[1] ?? '', 10);
return Number.isNaN(parsed) || parsed <= 0 ? undefined : parsed;
}
65 changes: 61 additions & 4 deletions src/embeddings/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,72 @@ export function parseEmbeddingProviderName(
return undefined;
}

export const DEFAULT_OLLAMA_MODEL = 'nomic-embed-text';
export const DEFAULT_TRANSFORMERS_MODEL = 'Xenova/bge-small-en-v1.5';
export const DEFAULT_OPENAI_MODEL = 'text-embedding-3-small';

// Default: bge-small (fast, ~2min indexing, consumer-hardware safe)
// Opt-in: set EMBEDDING_MODEL=onnx-community/granite-embedding-small-english-r2-ONNX for
// better conceptual search at the cost of 5-10x slower indexing and higher RAM usage
export const DEFAULT_MODEL = process.env.EMBEDDING_MODEL || 'Xenova/bge-small-en-v1.5';
export const DEFAULT_MODEL = process.env.EMBEDDING_MODEL || DEFAULT_TRANSFORMERS_MODEL;

const DEFAULT_OPENAI_API_ENDPOINT = 'https://api.openai.com/v1';
const DEFAULT_OLLAMA_HOST = 'http://localhost:11434';

function stripTrailingSlashes(value: string): string {
return value.replace(/\/+$/, '');
}

function resolveOpenAiApiEndpoint(): string {
const fromEnv = process.env.OPENAI_BASE_URL?.trim();
if (!fromEnv) {
return DEFAULT_OPENAI_API_ENDPOINT;
}
return stripTrailingSlashes(fromEnv);
}

export function resolveOllamaHost(): string {
const fromEnv = process.env.OLLAMA_HOST?.trim();
if (!fromEnv) {
return DEFAULT_OLLAMA_HOST;
}
return stripTrailingSlashes(fromEnv);
}

export function resolveEmbeddingModel(
provider: EmbeddingConfig['provider'],
envModel: string | undefined = process.env.EMBEDDING_MODEL
): string {
if (envModel) {
return envModel;
}
if (provider === 'openai') {
return DEFAULT_OPENAI_MODEL;
}
if (provider === 'ollama') {
return DEFAULT_OLLAMA_MODEL;
}
return DEFAULT_TRANSFORMERS_MODEL;
}

function resolveDefaultApiEndpoint(provider: EmbeddingConfig['provider']): string | undefined {
if (provider === 'openai') {
return resolveOpenAiApiEndpoint();
}
if (provider === 'ollama') {
return resolveOllamaHost();
}
return undefined;
}

const defaultProvider =
parseEmbeddingProviderName(process.env.EMBEDDING_PROVIDER) ?? 'transformers';

export const DEFAULT_EMBEDDING_CONFIG: EmbeddingConfig = {
provider: parseEmbeddingProviderName(process.env.EMBEDDING_PROVIDER) ?? 'transformers',
model: DEFAULT_MODEL,
provider: defaultProvider,
model: resolveEmbeddingModel(defaultProvider),
batchSize: 32,
maxRetries: 3,
apiKey: process.env.OPENAI_API_KEY
apiKey: process.env.OPENAI_API_KEY,
apiEndpoint: resolveDefaultApiEndpoint(defaultProvider)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Search calls the wrong server

Provider overrides inherit the endpoint chosen for the environment's provider. If an Ollama index is opened while EMBEDDING_PROVIDER=openai, search supplies the stored Ollama provider and model but retains OpenAI's default endpoint. The new Ollama branch then sends /api/show to OpenAI, so semantic search cannot start.

Resolve the default endpoint from the final provider after merging the caller's settings. Apply the same ordering to the model default.

};
39 changes: 39 additions & 0 deletions tests/embedding-config-env.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { afterEach, describe, expect, it, vi } from 'vitest';

describe('DEFAULT_EMBEDDING_CONFIG environment', () => {
afterEach(() => {
vi.unstubAllEnvs();
vi.resetModules();
});

it('uses OPENAI_BASE_URL when set', async () => {
vi.stubEnv('EMBEDDING_PROVIDER', 'openai');
vi.stubEnv('OPENAI_BASE_URL', 'http://127.0.0.1:11434/v1/');
const { DEFAULT_EMBEDDING_CONFIG } = await import('../src/embeddings/types.js');
expect(DEFAULT_EMBEDDING_CONFIG.apiEndpoint).toBe('http://127.0.0.1:11434/v1');
});

it('defaults to the official OpenAI endpoint when OPENAI_BASE_URL is unset', async () => {
vi.stubEnv('EMBEDDING_PROVIDER', 'openai');
vi.unstubAllEnvs();
vi.stubEnv('EMBEDDING_PROVIDER', 'openai');
const { DEFAULT_EMBEDDING_CONFIG } = await import('../src/embeddings/types.js');
expect(DEFAULT_EMBEDDING_CONFIG.apiEndpoint).toBe('https://api.openai.com/v1');
});

it('uses OLLAMA_HOST when provider is ollama', async () => {
vi.stubEnv('EMBEDDING_PROVIDER', 'ollama');
vi.stubEnv('OLLAMA_HOST', 'http://127.0.0.1:11434/');
const { DEFAULT_EMBEDDING_CONFIG } = await import('../src/embeddings/types.js');
expect(DEFAULT_EMBEDDING_CONFIG.provider).toBe('ollama');
expect(DEFAULT_EMBEDDING_CONFIG.apiEndpoint).toBe('http://127.0.0.1:11434');
expect(DEFAULT_EMBEDDING_CONFIG.model).toBe('nomic-embed-text');
});

it('resolveEmbeddingModel picks provider-specific defaults', async () => {
const { resolveEmbeddingModel } = await import('../src/embeddings/types.js');
expect(resolveEmbeddingModel('ollama', undefined)).toBe('nomic-embed-text');
expect(resolveEmbeddingModel('openai', undefined)).toBe('text-embedding-3-small');
expect(resolveEmbeddingModel('transformers', undefined)).toBe('Xenova/bge-small-en-v1.5');
});
});
4 changes: 4 additions & 0 deletions tests/embedding-mismatch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,4 +95,8 @@ describe('getConfiguredDimensions', () => {
it('returns 384 as fallback for unknown transformers model', () => {
expect(getConfiguredDimensions({ provider: 'transformers', model: 'some/unknown-model' })).toBe(384);
});

it('returns 768 for nomic-embed-text via ollama provider', () => {
expect(getConfiguredDimensions({ provider: 'ollama', model: 'nomic-embed-text' })).toBe(768);
});
});
Loading
Loading