diff --git a/README.md b/README.md index ed7149a..5fa3181 100644 --- a/README.md +++ b/README.md @@ -242,7 +242,7 @@ On VS Code's 2.45M‑line codebase, SocratiCode answers architectural questions - **Hybrid code search** — Built on Qdrant, a purpose-built vector database with HNSW indexing, concurrent read/write, and payload filtering. Each chunk stores both a dense vector and a BM25 sparse vector; the Query API runs both sub-queries in a single round-trip and fuses results with Reciprocal Rank Fusion (RRF). Semantic search handles conceptual queries like "authentication middleware" even when those exact words don't appear in the code. BM25 handles exact identifier and keyword lookups. You get the best of both in every query with no tuning required. - **Configurable Qdrant** — Use the built-in Docker Qdrant (default, zero config) or connect to your own instance (self-hosted, remote server, or Qdrant Cloud). Configure via `QDRANT_MODE`, `QDRANT_URL`, and `QDRANT_API_KEY` environment variables. - **Configurable Ollama** — Use the built-in Docker Ollama (default, zero config) or point to your own Ollama instance (native install -GPU access-, remote server, etc.). Configure via `OLLAMA_MODE`, `OLLAMA_URL`, `EMBEDDING_MODEL` and `EMBEDDING_DIMENSIONS` environment variables. -- **Multi-provider embeddings** — Switch between Local Ollama (private, GPU access), Docker Ollama (zero-config), OpenAI (`text-embedding-3-small`, fastest), Google Gemini (`gemini-embedding-001`, free tier), LM Studio (local OpenAI-compatible server), or LiteLLM (proxy gateway in front of 100+ providers) with a single environment variable. No provider-specific configuration files. +- **Multi-provider embeddings** — Switch between Text Embedder (deterministic Go binary, default, zero-dependency), Local Ollama (private, GPU access), Docker Ollama (zero-config), OpenAI (`text-embedding-3-small`, fastest), Google Gemini (`gemini-embedding-001`, free tier), LM Studio (local OpenAI-compatible server), or LiteLLM (proxy gateway in front of 100+ providers) with a single environment variable. No provider-specific configuration files. - **Private & secure** — Everything runs on your machine — your code never leaves your network. The default Docker setup includes Ollama (embeddings) and Qdrant (vector storage) with no external API calls. No API costs, no token limits. Suitable for air-gapped and on-premises environments. Optional cloud providers (OpenAI, Google Gemini, Qdrant Cloud) are available but never required. - **AST-aware chunking** — Files are split at function/class boundaries using AST parsing (ast-grep), not arbitrary line counts. This produces higher-quality search results. Falls back to line-based chunking for unsupported languages. - **Polyglot code dependency graph** — Static analysis of import/require/use/include statements using ast-grep for 18+ languages. No external tools like dependency-cruiser required. Detects circular dependencies and generates visual Mermaid diagrams. @@ -1154,7 +1154,7 @@ The rest of this section documents the variables themselves. Pass them using whi | Variable | Default | Description | |----------|---------|-------------| -| `EMBEDDING_PROVIDER` | `ollama` | Embedding backend: `ollama` (local, default), `openai`, `google`, `lmstudio`, or `litellm` | +| `EMBEDDING_PROVIDER` | `textembedder` | Embedding backend: `textembedder` (deterministic Go binary, default, no Docker/GPU needed), `ollama` (local), `openai`, `google`, `lmstudio`, or `litellm` | | `EMBEDDING_MODEL` | *(per provider)* | Model name. Defaults: `nomic-embed-text` (ollama), `text-embedding-3-small` (openai), `gemini-embedding-001` (google). **Required** for `lmstudio` and `litellm` (no default). | | `EMBEDDING_DIMENSIONS` | *(per provider)* | Vector dimensions. Defaults: `768` (ollama), `1536` (openai), `3072` (google). **Required** for `lmstudio` and `litellm` (no default; varies per loaded model / proxy alias). | | `EMBEDDING_CONTEXT_LENGTH` | *(auto-detected)* | Model context window in tokens. Auto-detected for known model names (works for LiteLLM aliases that match the underlying model name). Set manually for custom LM Studio models or arbitrary LiteLLM aliases. | @@ -1191,6 +1191,23 @@ The rest of this section documents the variables themselves. Pass them using whi | `LITELLM_API_KEY` | *(none)* | **Required.** Master key (`general_settings.master_key` in the proxy's `config.yaml`) or a virtual key issued via LiteLLM's `/key/generate` endpoint. Unlike LM Studio, LiteLLM always authenticates — `/v1/models` itself is gated. | | `LITELLM_SEND_DIMENSIONS` | `false` | Opt-in (`true` / `1` / `yes`). Forwards the OpenAI-style `dimensions` parameter through the proxy. Safe only for Matryoshka-aware backends (`text-embedding-3-*`, `voyage-3`); other backends (BGE, `nomic-embed-text`, Cohere v3) reject the request. Leave unset unless you know your alias resolves to a Matryoshka model. | +### Text-Embedder Configuration (when `EMBEDDING_PROVIDER=textembedder`) + +The default embedding provider. Uses a deterministic Go binary (`text-embedder`) that produces bit-identical 768-dim vectors — no Docker, no GPU, no API keys, no network calls. The binary is distributed in gzip form (`text-embedder-*.gz`) and auto-extracted on first use. + +| Variable | Default | Description | +|----------|---------|-------------| +| `TEXTEMBEDDER_BIN_PATH` | *(none)* | Absolute path to an already-decompressed `text-embedder` binary. Useful when you manage the binary yourself or the auto-discovery fails. | +| `TEXTEMBEDDER_URL` | *(none)* | When set, skips binary management entirely and connects to an externally running instance (e.g. `http://localhost:8089`). Useful for sharing one instance across multiple MCP hosts. | +| `TEXTEMBEDDER_PORT` | `8089` | Port the binary listens on when spawned as a subprocess. | + +**Platform binaries:** +- `text-embedder-linux.gz` — linux/amd64 +- `text-embedder-darwin.gz` — darwin/arm64 (Apple Silicon) +- `text-embedder-win.gz` — windows/amd64 + +The provider selects the correct binary based on `process.platform`. To generate them, run `make deploy-all` from the [`text-embedder`](https://github.com/guiperry/text-embedder) directory. + ### Qdrant Configuration | Variable | Default | Description | diff --git a/src/services/embedding-config.ts b/src/services/embedding-config.ts index d4fcd4c..5b6cea8 100644 --- a/src/services/embedding-config.ts +++ b/src/services/embedding-config.ts @@ -4,7 +4,11 @@ * Embedding provider configuration — loaded from environment variables (MCP config). * * EMBEDDING_PROVIDER: - * - "ollama" (default): Use Ollama for embeddings (Docker or external). + * - "textembedder" (default): Use the deterministic text-embedder Go binary + * (Landmark Lattice v1). Zero dependencies, bit-perfect, + * 768-dim int32 vectors. The binary is spawned automatically + * or you can run it externally and set TEXTEMBEDDER_URL. + * - "ollama": Use Ollama for embeddings (Docker or external). * - "openai": Use OpenAI Embeddings API. Requires OPENAI_API_KEY. * - "google": Use Google Generative AI Embedding API. Requires GOOGLE_API_KEY. * - "lmstudio": Use a local LM Studio server (OpenAI-compatible). Requires @@ -14,6 +18,13 @@ * EMBEDDING_MODEL (must match an alias in the proxy's config.yaml), * and EMBEDDING_DIMENSIONS (the alias's underlying dim). * + * Text-Embedder-specific: + * TEXTEMBEDDER_URL: HTTP URL of an external text-embedder instance. + * When set, the provider skips spawning a local binary. + * TEXTEMBEDDER_BIN_PATH: Path to the text-embedder binary. + * Default: /text-embedder + * TEXTEMBEDDER_PORT: Port for the subprocess (default: 8089). + * * Ollama-specific: * OLLAMA_MODE: * - "auto" (default): Auto-detect. If Ollama is already running natively on port 11434, @@ -61,7 +72,7 @@ import { logger } from "./logger.js"; // ── Types ───────────────────────────────────────────────────────────────── -export type EmbeddingProvider = "ollama" | "openai" | "google" | "lmstudio" | "litellm"; +export type EmbeddingProvider = "textembedder" | "ollama" | "openai" | "google" | "lmstudio" | "litellm"; export type OllamaMode = "docker" | "external" | "auto"; export interface EmbeddingConfig { @@ -91,11 +102,12 @@ export interface EmbeddingConfig { * selected without explicit EMBEDDING_MODEL / EMBEDDING_DIMENSIONS. */ const PROVIDER_DEFAULTS: Record = { - ollama: { model: "nomic-embed-text", dimensions: 768 }, - openai: { model: "text-embedding-3-small", dimensions: 1536 }, - google: { model: "gemini-embedding-001", dimensions: 3072 }, - lmstudio: { model: "", dimensions: 0 }, - litellm: { model: "", dimensions: 0 }, + textembedder: { model: "landmark-lattice-v1", dimensions: 768 }, + ollama: { model: "nomic-embed-text", dimensions: 768 }, + openai: { model: "text-embedding-3-small", dimensions: 1536 }, + google: { model: "gemini-embedding-001", dimensions: 3072 }, + lmstudio: { model: "", dimensions: 0 }, + litellm: { model: "", dimensions: 0 }, }; // ── Ollama mode defaults ────────────────────────────────────────────────── @@ -114,6 +126,8 @@ const MODE_DEFAULTS: Record = { * and to stay within cloud provider limits. */ const MODEL_CONTEXT_LENGTHS: Record = { + // Text-Embedder (Landmark Lattice — no real context limit, use generous default) + "landmark-lattice-v1": 8192, // Ollama models "nomic-embed-text": 2048, "mxbai-embed-large": 512, @@ -145,8 +159,9 @@ export function loadEmbeddingConfig(): EmbeddingConfig { if (_config) return _config; // ── Provider ──────────────────────────────────────────────────────── - const rawProvider = process.env.EMBEDDING_PROVIDER || "ollama"; + const rawProvider = process.env.EMBEDDING_PROVIDER || "textembedder"; if ( + rawProvider !== "textembedder" && rawProvider !== "ollama" && rawProvider !== "openai" && rawProvider !== "google" && @@ -154,7 +169,7 @@ export function loadEmbeddingConfig(): EmbeddingConfig { rawProvider !== "litellm" ) { throw new Error( - `Invalid EMBEDDING_PROVIDER: "${rawProvider}". Must be "ollama", "openai", "google", "lmstudio", or "litellm".`, + `Invalid EMBEDDING_PROVIDER: "${rawProvider}". Must be "textembedder", "ollama", "openai", "google", "lmstudio", or "litellm".`, ); } const embeddingProvider: EmbeddingProvider = rawProvider; @@ -274,17 +289,19 @@ export function loadEmbeddingConfig(): EmbeddingConfig { embeddingModel: _config.embeddingModel, embeddingDimensions: _config.embeddingDimensions, embeddingContextLength: _config.embeddingContextLength || "auto", - hasApiKey: !!(embeddingProvider === "ollama" - ? _config.ollamaApiKey - : embeddingProvider === "openai" - ? process.env.OPENAI_API_KEY - : embeddingProvider === "google" - ? process.env.GOOGLE_API_KEY - : embeddingProvider === "lmstudio" - ? process.env.LMSTUDIO_API_KEY - : embeddingProvider === "litellm" - ? process.env.LITELLM_API_KEY - : undefined), + hasApiKey: !!(embeddingProvider === "textembedder" + ? true // binary / external URL; no key needed + : embeddingProvider === "ollama" + ? _config.ollamaApiKey + : embeddingProvider === "openai" + ? process.env.OPENAI_API_KEY + : embeddingProvider === "google" + ? process.env.GOOGLE_API_KEY + : embeddingProvider === "lmstudio" + ? process.env.LMSTUDIO_API_KEY + : embeddingProvider === "litellm" + ? process.env.LITELLM_API_KEY + : undefined), }); return _config; diff --git a/src/services/embedding-provider.ts b/src/services/embedding-provider.ts index 8ee70cd..50744dd 100644 --- a/src/services/embedding-provider.ts +++ b/src/services/embedding-provider.ts @@ -8,7 +8,8 @@ * about which backend generates the vectors. * * Providers: - * - ollama (default) — local Ollama (Docker or external) + * - textembedder (default) — deterministic Go binary (zero deps, bit-perfect) + * - ollama — local Ollama (Docker or external) * - openai — OpenAI Embeddings API (text-embedding-3-small, etc.) * - google — Google Generative AI Embedding API (gemini-embedding-001, etc.) * - lmstudio — local LM Studio server via OpenAI-compatible API @@ -46,6 +47,11 @@ export async function getEmbeddingProvider(onProgress?: InfraProgressCallback): logger.info("Initializing embedding provider", { provider: name }); switch (name) { + case "textembedder": { + const { TextEmbedderEmbeddingProvider } = await import("./provider-textembedder.js"); + _provider = new TextEmbedderEmbeddingProvider(); + break; + } case "ollama": { // Dynamic imports avoid loading all provider SDKs at startup. const { OllamaEmbeddingProvider } = await import("./provider-ollama.js"); @@ -74,7 +80,7 @@ export async function getEmbeddingProvider(onProgress?: InfraProgressCallback): } default: throw new Error( - `Unknown embedding provider: "${name}". Must be "ollama", "openai", "google", "lmstudio", or "litellm".`, + `Unknown embedding provider: "${name}". Must be "textembedder", "ollama", "openai", "google", "lmstudio", or "litellm".`, ); } diff --git a/src/services/provider-textembedder.ts b/src/services/provider-textembedder.ts new file mode 100644 index 0000000..bba8262 --- /dev/null +++ b/src/services/provider-textembedder.ts @@ -0,0 +1,440 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (C) 2026 Giancarlo Erra - Altaire Limited +/** + * Text-Embedder embedding provider. + * + * Wraps the deterministic text-embedder Go binary (Landmark Lattice v1) + * which produces bit-identical 768-dim int32 vectors scaled to [0, 10000]. + * + * The provider converts these to float64 [0, 1] for Qdrant by dividing + * by FixedPointScale (10000). + * + * Two modes: + * 1. Binary mode (default): the binary is distributed as text-embedder.gz. + * The provider decompresses it to a temp directory on first use and + * spawns it as a subprocess. + * 2. External mode: user runs the binary themselves and sets TEXTEMBEDDER_URL + * pointing at the HTTP API (e.g. http://localhost:8089). + * + * Optional env: + * TEXTEMBEDDER_URL=http://localhost:8089 (external mode — skip binary) + * TEXTEMBEDDER_BIN_PATH= (uncompressed binary path) + * TEXTEMBEDDER_PORT=8089 (port for subprocess) + */ + +import { spawn, type ChildProcess } from "node:child_process"; +import fsp from "node:fs/promises"; +import { constants } from "node:fs"; +import path from "node:path"; +import os from "node:os"; +import { fileURLToPath } from "node:url"; +import { gunzipSync } from "node:zlib"; +import { getEmbeddingConfig } from "./embedding-config.js"; +import type { EmbeddingHealthStatus, EmbeddingProvider, EmbeddingReadinessResult } from "./embedding-types.js"; +import { logger } from "./logger.js"; + +// ── Constants ─────────────────────────────────────────────────────────── + +const FIXED_POINT_SCALE = 10000; +const TEXTEMBEDDER_BATCH_SIZE = 64; +const BINARY_START_TIMEOUT_MS = 10_000; +const HEALTH_POLL_MS = 200; + +/** Temp dir where the decompressed binary lives. */ +const TMP_DIR = path.join(os.tmpdir(), "socraticode-textembedder"); +const TMP_BIN_PATH = path.join(TMP_DIR, "text-embedder"); + +// ── Binary path discovery ────────────────────────────────────────────── + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const PLATFORM_MAP: Record = { + linux: "text-embedder-linux.gz", + darwin: "text-embedder-darwin.gz", + win32: "text-embedder-win.gz", +}; + +function platformGzName(): string | null { + return PLATFORM_MAP[process.platform] ?? null; +} + +/** + * Candidates for the gzipped binary, checked in order. + * Platform-specific binaries take priority over the generic fallback name. + */ +function gzCandidates(): string[] { + const candidates: string[] = []; + + const envBin = process.env.TEXTEMBEDDER_BIN_PATH; + if (envBin) candidates.push(envBin); + + const pfx = platformGzName(); + if (pfx) { + candidates.push( + path.resolve(process.cwd(), pfx), + path.resolve(__dirname, pfx), + path.resolve(__dirname, "..", pfx), + path.resolve(__dirname, "..", "..", pfx), + ); + } + + candidates.push( + path.resolve(process.cwd(), "text-embedder"), + path.resolve(process.cwd(), "text-embedder.gz"), + path.resolve(__dirname, "text-embedder.gz"), + path.resolve(__dirname, "..", "text-embedder.gz"), + path.resolve(__dirname, "..", "..", "text-embedder.gz"), + ); + + return candidates; +} + +/** + * Resolve the gzipped (or bare) binary path. Returns { gzPath, isCompressed }. + * Returns null if nothing is found. + */ +async function resolveBinarySource(): Promise<{ sourcePath: string; isCompressed: boolean } | null> { + for (const candidate of gzCandidates()) { + try { + await fsp.access(candidate, constants.R_OK); + const isCompressed = candidate.endsWith(".gz"); + return { sourcePath: candidate, isCompressed }; + } catch { + // not here + } + } + return null; +} + +// ── Decompression ────────────────────────────────────────────────────── + +/** Path to an already-decompressed cached binary. */ +let extractedBinPath: string | null = null; + +/** + * Ensure the decompressed binary exists in the temp dir, extracting it from + * the gzipped package asset if needed. Returns the path to the binary. + */ +async function ensureBinaryExtracted(sourcePath: string): Promise { + // Fast path: already decompressed and file still exists + if (extractedBinPath) { + try { + await fsp.access(extractedBinPath, constants.X_OK); + return extractedBinPath; + } catch { + extractedBinPath = null; + } + } + + logger.info("Decompressing text-embedder binary", { source: sourcePath }); + + // Read gzipped data and decompress + const compressed = await fsp.readFile(sourcePath); + const decompressed = gunzipSync(compressed); + + // Ensure temp dir exists + await fsp.mkdir(TMP_DIR, { recursive: true }); + + // Write with executable permissions + await fsp.writeFile(TMP_BIN_PATH, decompressed, { mode: 0o755 }); + + extractedBinPath = TMP_BIN_PATH; + return extractedBinPath; +} + +// ── Subprocess management ────────────────────────────────────────────── + +let subprocess: ChildProcess | null = null; +let subprocessUrl: string | null = null; +let binaryStarting = false; + +async function startBinary(port: number): Promise { + if (subprocessUrl) return subprocessUrl; + if (binaryStarting) { + return new Promise((resolve, reject) => { + const interval = setInterval(() => { + if (subprocessUrl) { + clearInterval(interval); + resolve(subprocessUrl); + } + if (!subprocess && !binaryStarting) { + clearInterval(interval); + reject(new Error("Binary failed to start")); + } + }, HEALTH_POLL_MS); + }); + } + + binaryStarting = true; + const source = await resolveBinarySource(); + + if (!source) { + binaryStarting = false; + const pfx = platformGzName(); + throw new Error( + `text-embedder binary not found for platform "${process.platform}". ` + + `Run 'make deploy-all' from the text-embedder directory to generate ` + + (pfx ? `${pfx} (expected name), ` : "") + + "or set TEXTEMBEDDER_BIN_PATH / TEXTEMBEDDER_URL.", + ); + } + + // Decompress if necessary, or use bare binary directly + const binPath = source.isCompressed + ? await ensureBinaryExtracted(source.sourcePath) + : source.sourcePath; + + const url = `http://localhost:${port}`; + logger.info("Starting text-embedder binary", { binPath, port }); + + subprocess = spawn(binPath, [`--addr=:${port}`], { + stdio: ["ignore", "pipe", "pipe"], + detached: false, + }); + + subprocess.on("error", (err) => { + logger.error("text-embedder binary failed to start", { error: err.message }); + subprocess = null; + binaryStarting = false; + }); + + subprocess.on("exit", (code, signal) => { + logger.info("text-embedder binary exited", { code, signal }); + subprocess = null; + subprocessUrl = null; + binaryStarting = false; + }); + + const logStream = (data: Buffer) => { + for (const line of data.toString().split("\n").filter(Boolean)) { + logger.debug(`[text-embedder] ${line}`); + } + }; + subprocess.stdout?.on("data", logStream); + subprocess.stderr?.on("data", logStream); + + const deadline = Date.now() + BINARY_START_TIMEOUT_MS; + while (Date.now() < deadline) { + try { + const resp = await fetch(`${url}/health`); + if (resp.ok) { + logger.info("text-embedder binary is ready", { url }); + subprocessUrl = url; + binaryStarting = false; + return url; + } + } catch { + // Not ready yet + } + await new Promise((r) => setTimeout(r, HEALTH_POLL_MS)); + } + + killBinary(); + binaryStarting = false; + throw new Error( + `text-embedder binary did not become ready within ${BINARY_START_TIMEOUT_MS / 1000}s. ` + + "Check the binary is compatible with your system.", + ); +} + +function killBinary(): void { + if (subprocess) { + try { + subprocess.kill("SIGTERM"); + setTimeout(() => { + if (subprocess) { + try { subprocess.kill("SIGKILL"); } catch { /* ignore */ } + } + }, 2000); + } catch { + // Already dead + } + subprocess = null; + subprocessUrl = null; + } +} + +let cleanupRegistered = false; +function registerCleanup(): void { + if (cleanupRegistered) return; + cleanupRegistered = true; + const cleanup = () => { + killBinary(); + // Best-effort cleanup of the temp binary + fsp.rm(TMP_BIN_PATH, { force: true }).catch(() => {}); + fsp.rm(TMP_DIR, { force: true, recursive: true }).catch(() => {}); + }; + process.on("exit", cleanup); + process.on("SIGINT", () => { cleanup(); process.exit(0); }); + process.on("SIGTERM", () => { cleanup(); process.exit(0); }); +} + +// ── HTTP client ───────────────────────────────────────────────────────── + +interface EmbedResponse { + embedding: number[]; + dimensions: number; + model: string; + token_count: number; +} + +interface BatchItem { + index: number; + embedding: number[]; + dimensions: number; + token_count: number; +} + +interface BatchResponse { + results: BatchItem[]; + model: string; + count: number; +} + +interface HealthResponse { + status: string; + model: string; + dims: number; + timestamp: string; +} + +async function resolveBaseUrl(): Promise { + const externalUrl = process.env.TEXTEMBEDDER_URL; + if (externalUrl) return externalUrl.replace(/\/+$/, ""); + + if (subprocessUrl) return subprocessUrl; + + const port = Number(process.env.TEXTEMBEDDER_PORT) || 8089; + return startBinary(port); +} + +function unscaleVector(intVec: number[]): number[] { + return intVec.map((v) => v / FIXED_POINT_SCALE); +} + +// ── Provider class ────────────────────────────────────────────────────── + +export class TextEmbedderEmbeddingProvider implements EmbeddingProvider { + readonly name = "textembedder"; + + async ensureReady(): Promise { + registerCleanup(); + const baseUrl = await resolveBaseUrl(); + + try { + const resp = await fetch(`${baseUrl}/health`); + if (!resp.ok) { + throw new Error(`Health check returned status ${resp.status}`); + } + const health: HealthResponse = await resp.json() as HealthResponse; + logger.info("text-embedder provider ready", { + model: health.model, + dims: health.dims, + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new Error( + `text-embedder is not reachable at ${baseUrl}. ` + + "Make sure the binary is running or set TEXTEMBEDDER_URL to point at an external instance. " + + `Underlying error: ${message}`, + ); + } + + return { modelPulled: false, containerStarted: false, imagePulled: false }; + } + + async embed(texts: string[]): Promise { + if (texts.length === 0) return []; + + const baseUrl = await resolveBaseUrl(); + const results: number[][] = []; + + for (let i = 0; i < texts.length; i += TEXTEMBEDDER_BATCH_SIZE) { + const batch = texts.slice(i, i + TEXTEMBEDDER_BATCH_SIZE); + const batchResults = await this._embedBatch(baseUrl, batch); + results.push(...batchResults); + } + return results; + } + + async embedSingle(text: string): Promise { + const baseUrl = await resolveBaseUrl(); + + const response = await fetch(`${baseUrl}/embed`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ text }), + }); + + if (!response.ok) { + const body = await response.text().catch(() => ""); + throw new Error( + `text-embedder /embed failed (${response.status}): ${body}`, + ); + } + + const data: EmbedResponse = await response.json() as EmbedResponse; + return unscaleVector(data.embedding); + } + + async healthCheck(): Promise { + const lines: string[] = []; + const icon = (ok: boolean) => (ok ? "[OK]" : "[MISSING]"); + + const externalUrl = process.env.TEXTEMBEDDER_URL; + + if (externalUrl) { + lines.push(`${icon(true)} text-embedder mode: external (${externalUrl})`); + } else { + const source = await resolveBinarySource(); + const binaryOk = !!source; + lines.push( + `${icon(binaryOk)} text-embedder binary: ` + + (binaryOk + ? `Found at ${source!.sourcePath}${source!.isCompressed ? " (gzipped)" : ""}` + : `Not found — run 'make deploy-all' from text-embedder dir`), + ); + if (!binaryOk) { + return { available: false, modelReady: false, statusLines: lines }; + } + lines.push(`${icon(true)} text-embedder mode: binary (decompresses on first use)`); + } + + try { + const baseUrl = await resolveBaseUrl(); + const resp = await fetch(`${baseUrl}/health`); + if (!resp.ok) { + lines.push(`${icon(false)} text-embedder: Health check failed (status ${resp.status})`); + return { available: false, modelReady: false, statusLines: lines }; + } + const health: HealthResponse = await resp.json() as HealthResponse; + lines.push(`${icon(true)} text-embedder: Reachable at ${baseUrl}`); + lines.push(`${icon(true)} Model: ${health.model} (${health.dims} dims)`); + return { available: true, modelReady: true, statusLines: lines }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + lines.push(`${icon(false)} text-embedder: Not reachable (${message})`); + return { available: false, modelReady: false, statusLines: lines }; + } + } + + private async _embedBatch(baseUrl: string, texts: string[]): Promise { + const response = await fetch(`${baseUrl}/embed/batch`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ texts }), + }); + + if (!response.ok) { + const body = await response.text().catch(() => ""); + throw new Error( + `text-embedder /embed/batch failed (${response.status}): ${body}`, + ); + } + + const data: BatchResponse = await response.json() as BatchResponse; + const sorted = data.results.sort((a, b) => a.index - b.index); + return sorted.map((r) => unscaleVector(r.embedding)); + } +} diff --git a/tests/unit/embedding-config.test.ts b/tests/unit/embedding-config.test.ts index 00bbe65..0daa9af 100644 --- a/tests/unit/embedding-config.test.ts +++ b/tests/unit/embedding-config.test.ts @@ -36,24 +36,14 @@ describe("embedding-config", () => { }); describe("defaults (no env vars)", () => { - it("defaults to ollama provider", () => { + it("defaults to textembedder provider", () => { const config = loadEmbeddingConfig(); - expect(config.embeddingProvider).toBe("ollama"); + expect(config.embeddingProvider).toBe("textembedder"); }); - it("defaults to auto mode", () => { + it("defaults model to landmark-lattice-v1", () => { const config = loadEmbeddingConfig(); - expect(config.ollamaMode).toBe("auto"); - }); - - it("defaults URL to localhost:11434 in auto mode", () => { - const config = loadEmbeddingConfig(); - expect(config.ollamaUrl).toBe("http://localhost:11434"); - }); - - it("defaults model to nomic-embed-text", () => { - const config = loadEmbeddingConfig(); - expect(config.embeddingModel).toBe("nomic-embed-text"); + expect(config.embeddingModel).toBe("landmark-lattice-v1"); }); it("defaults dimensions to 768", () => { @@ -61,7 +51,7 @@ describe("embedding-config", () => { expect(config.embeddingDimensions).toBe(768); }); - it("has no API key by default", () => { + it("has no Ollama API key by default", () => { const config = loadEmbeddingConfig(); expect(config.ollamaApiKey).toBeUndefined(); }); @@ -145,11 +135,12 @@ describe("embedding-config", () => { describe("singleton behavior", () => { it("caches config after first load", () => { const first = loadEmbeddingConfig(); + expect(first.embeddingProvider).toBe("textembedder"); // Change env — should NOT affect cached config - process.env.OLLAMA_MODE = "external"; + process.env.EMBEDDING_PROVIDER = "google"; const second = loadEmbeddingConfig(); expect(second).toBe(first); // same reference - expect(second.ollamaMode).toBe("auto"); + expect(second.embeddingProvider).toBe("textembedder"); }); it("getEmbeddingConfig returns same as loadEmbeddingConfig", () => { @@ -160,18 +151,19 @@ describe("embedding-config", () => { it("resetEmbeddingConfig clears cache", () => { const first = loadEmbeddingConfig(); - expect(first.ollamaMode).toBe("auto"); + expect(first.embeddingProvider).toBe("textembedder"); resetEmbeddingConfig(); - process.env.OLLAMA_MODE = "external"; + process.env.EMBEDDING_PROVIDER = "google"; const second = loadEmbeddingConfig(); - expect(second.ollamaMode).toBe("external"); + expect(second.embeddingProvider).toBe("google"); expect(second).not.toBe(first); }); }); describe("full external config", () => { it("handles complete external config with all options", () => { + process.env.EMBEDDING_PROVIDER = "ollama"; process.env.OLLAMA_MODE = "external"; process.env.OLLAMA_URL = "http://remote-gpu:11434"; process.env.EMBEDDING_MODEL = "mxbai-embed-large"; @@ -194,10 +186,10 @@ describe("embedding-config", () => { }); describe("embedding provider selection", () => { - it("defaults to ollama when EMBEDDING_PROVIDER is not set", () => { + it("defaults to textembedder when EMBEDDING_PROVIDER is not set", () => { const config = loadEmbeddingConfig(); - expect(config.embeddingProvider).toBe("ollama"); - expect(config.embeddingModel).toBe("nomic-embed-text"); + expect(config.embeddingProvider).toBe("textembedder"); + expect(config.embeddingModel).toBe("landmark-lattice-v1"); expect(config.embeddingDimensions).toBe(768); }); @@ -222,7 +214,7 @@ describe("embedding-config", () => { it("throws for invalid EMBEDDING_PROVIDER", () => { process.env.EMBEDDING_PROVIDER = "anthropic"; expect(() => loadEmbeddingConfig()).toThrow( - 'Invalid EMBEDDING_PROVIDER: "anthropic". Must be "ollama", "openai", "google", "lmstudio", or "litellm".', + 'Invalid EMBEDDING_PROVIDER: "anthropic". Must be "textembedder", "ollama", "openai", "google", "lmstudio", or "litellm".', ); }); diff --git a/tests/unit/embedding-provider.test.ts b/tests/unit/embedding-provider.test.ts index d955eca..6a98ab3 100644 --- a/tests/unit/embedding-provider.test.ts +++ b/tests/unit/embedding-provider.test.ts @@ -33,7 +33,13 @@ describe("embedding-provider", () => { }); describe("factory", () => { - it("defaults to OllamaEmbeddingProvider", async () => { + it("defaults to TextEmbedderEmbeddingProvider", async () => { + const provider = await getEmbeddingProvider(); + expect(provider.name).toBe("textembedder"); + }); + + it("creates OllamaEmbeddingProvider when configured", async () => { + process.env.EMBEDDING_PROVIDER = "ollama"; const provider = await getEmbeddingProvider(); expect(provider.name).toBe("ollama"); }); @@ -75,7 +81,7 @@ describe("embedding-provider", () => { it("recreates provider when config changes", async () => { const p1 = await getEmbeddingProvider(); - expect(p1.name).toBe("ollama"); + expect(p1.name).toBe("textembedder"); resetEmbeddingConfig(); resetEmbeddingProvider(); diff --git a/text-embedder-darwin.gz b/text-embedder-darwin.gz new file mode 100755 index 0000000..33cdf03 Binary files /dev/null and b/text-embedder-darwin.gz differ diff --git a/text-embedder-linux.gz b/text-embedder-linux.gz new file mode 100755 index 0000000..34c7518 Binary files /dev/null and b/text-embedder-linux.gz differ diff --git a/text-embedder-win.gz b/text-embedder-win.gz new file mode 100755 index 0000000..7df310b Binary files /dev/null and b/text-embedder-win.gz differ