Repository navigation
feat(embeddings): native Ollama embedding provider #146
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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'); | ||
| } | ||
| } | ||
|
|
@@ -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); | ||
| // Look up from the same MODEL_CONFIGS the provider uses — avoids stale hardcoded guesses | ||
| return MODEL_CONFIGS[model]?.dimensions ?? 384; | ||
| } | ||
|
|
@@ -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, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Ollama is selected through project configuration or Useful? React with 👍 / 👎. |
||
| mergedConfig.apiEndpoint ?? resolveOllamaHost() | ||
| ); | ||
| await provider.initialize(); | ||
| cachedProvider = provider; | ||
| cachedProviderType = providerKey; | ||
| return provider; | ||
| } | ||
|
|
||
| const provider = new TransformersEmbeddingProvider(mergedConfig.model); | ||
|
|
||
| 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; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For an Ollama model outside the hardcoded map that returns vectors other than 768 dimensions, a full build succeeds because 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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 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; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Provider overrides inherit the endpoint chosen for the environment's provider. If an Ollama index is opened while Resolve the default endpoint from the final provider after merging the caller's settings. Apply the same ordering to the model default. |
||
| }; | ||
| 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'); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
getConfiguredDimensionskeeps using a fixed guess after Ollama returns the actual vector size. If a custom model returns 1024 values andOLLAMA_EMBEDDING_DIMENSIONSis unset, a full build succeeds. A later incremental refresh compares those stored vectors against 768 and throwsIndexCorruptedError.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