From 6a9acae80b2cdc2ecd527e1557be2a3c56211710 Mon Sep 17 00:00:00 2001 From: iksnerd Date: Fri, 1 May 2026 21:32:54 +0300 Subject: [PATCH] =?UTF-8?q?v1.2.0=20=E2=80=94=20embeddinggemma=20default?= =?UTF-8?q?=20+=20concurrency/timeout=20bug=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix concurrency race in collection switch: mcp_server.ex handle_tool_call("reindex", ...) now pre-checks Indexer.busy?/0 before calling IndexManagement.ensure_collection_for_project/1. Previously, a rejected reindex would still swap the active Qdrant collection out from under in-flight Broadway batches, producing hundreds of "404 Not found: Collection nexus_X doesn't exist" errors until container restart. Fix cold-start Ollama timeouts dropping chunks: embed_batch/1 retries up to 3 times with linear backoff on :timeout / :connect_timeout / :econnrefused. Default recv_timeout raised from 30s to 60s. Timeout, retry count and backoff are configurable via Application env so tests can fast-fail. EmbeddingModel.warm_up/0 runs from Application.start/2 so the first real batch doesn't block on a cold model load. Default model switched to embeddinggemma:300m: @default_model in embedding_model.ex, plus updates in docker-compose.yml (already in v1.1.x), .env.example, config.exs, CLAUDE.md, README.md, docs/DOCKERHUB.md. OLLAMA_MODEL=nomic-embed-text still works as override. Bumps version to 1.2.0. --- .env.example | 5 +- CLAUDE.md | 8 +-- README.md | 12 +++- config/config.exs | 2 +- config/test.exs | 7 +++ docs/DOCKERHUB.md | 6 +- lib/elixir_nexus/application.ex | 5 ++ lib/elixir_nexus/embedding_model.ex | 53 +++++++++++++++-- lib/elixir_nexus/indexer.ex | 5 ++ lib/elixir_nexus/mcp_server.ex | 91 +++++++++++++++-------------- mix.exs | 2 +- 11 files changed, 134 insertions(+), 62 deletions(-) diff --git a/.env.example b/.env.example index f64533d..78615b3 100644 --- a/.env.example +++ b/.env.example @@ -5,8 +5,9 @@ # In Docker, use host.docker.internal to reach the host machine # OLLAMA_URL=http://localhost:11434 -# Ollama embedding model (default: nomic-embed-text) -# OLLAMA_MODEL=nomic-embed-text +# Ollama embedding model (default: embeddinggemma:300m, 768-dim) +# Alternative: nomic-embed-text (also 768-dim, faster on CPU) +# OLLAMA_MODEL=embeddinggemma:300m # Port for the MCP Streamable HTTP server (optional; when set, starts HTTP transport alongside Phoenix) # MCP_HTTP_PORT=3002 diff --git a/CLAUDE.md b/CLAUDE.md index d158945..6385d31 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -72,7 +72,7 @@ Tests run with `skip_compilation?: true` so they don't need Rust/Cargo in PATH. ## Running -**Prerequisites:** Ollama running on host with `nomic-embed-text` model pulled (`ollama pull nomic-embed-text`). +**Prerequisites:** Ollama running on host with `embeddinggemma:300m` pulled (`ollama pull embeddinggemma:300m`). Override with `OLLAMA_MODEL=nomic-embed-text` if you prefer the older default. ```bash # Docker — starts Phoenix :4100 + MCP Streamable HTTP :3002 in a single BEAM @@ -100,7 +100,7 @@ On failure (path not found or no source dirs), the error message lists available For building/testing CodeNexus itself: ```bash -ollama pull nomic-embed-text # Ensure embedding model +ollama pull embeddinggemma:300m # Ensure embedding model docker-compose up -d qdrant # Qdrant only nohup mix phx.server > /tmp/nexus_server.log 2>&1 & # Phoenix dashboard mix mcp # MCP stdio transport @@ -113,7 +113,7 @@ In local mode, MCP and Phoenix are separate BEAM instances sharing Qdrant but no - **MCP Server** (`lib/elixir_nexus/mcp_server.ex`) — stdio + HTTP (Streamable HTTP at `/mcp`) transport, ex_mcp 0.9.0 - **Phoenix Dashboard** (`lib/elixir_nexus_web/`) — LiveView dashboard on port 4100, auto-syncs from Qdrant -- **Indexing Pipeline** — Broadway-based: parse (tree-sitter/sourceror) -> chunk -> embed (Ollama nomic-embed-text, 768-dim) -> store (Qdrant + ETS) +- **Indexing Pipeline** — Broadway-based: parse (tree-sitter/sourceror) -> chunk -> embed (Ollama embeddinggemma:300m, 768-dim) -> store (Qdrant + ETS) - **ETS Caches** — `ChunkCache` (chunks by file) + `GraphCache` (call graph nodes) — owned by `CacheOwner` GenServer - **TF-IDF ETS** — IDF vocabulary in ETS with `read_concurrency: true` for lock-free concurrent embeddings - **Supervision** — `rest_for_one` strategy: if a dependency crashes, all processes started after it restart @@ -203,7 +203,7 @@ Key node types that must be included: | `lib/elixir_nexus/mcp_server/resources.ex` | MCP resource generators (overview, architecture, hotspots) | | `lib/mix/tasks/mcp_http.ex` | Mix task for HTTP/SSE MCP transport | | `lib/elixir_nexus/project_switcher.ex` | Collection switching + ETS reload from Qdrant | -| `lib/elixir_nexus/embedding_model.ex` | Ollama nomic-embed-text client (768-dim dense embeddings) | +| `lib/elixir_nexus/embedding_model.ex` | Ollama embedding client (768-dim, default `embeddinggemma:300m`); retries on timeout/cold-start; `warm_up/0` called from Application | | `lib/elixir_nexus/tfidf_embedder.ex` | TF-IDF embedder with ETS-backed IDF for concurrent reads (fallback + sparse) | | `lib/elixir_nexus/dirty_tracker.ex` | SHA256-based incremental indexing (polyglot) | | `lib/elixir_nexus/qdrant_client.ex` | Qdrant GenServer — collection management, hybrid search, point ops; read-only calls bypass mailbox | diff --git a/README.md b/README.md index 6fa858c..29c49fc 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ graph TB subgraph Indexing["Indexing Pipeline (Broadway)"] CH["Chunker
semantic chunks"] - OL["Ollama nomic-embed-text
768-dim dense vectors"] + OL["Ollama embeddinggemma:300m
768-dim dense vectors"] TFIDF["TF-IDF
sparse keyword vectors"] end @@ -149,7 +149,7 @@ graph LR GR --> R["Results"] ``` -1. **Dense embedding** via Ollama nomic-embed-text (falls back to TF-IDF) +1. **Dense embedding** via Ollama (default `embeddinggemma:300m`, falls back to TF-IDF) 2. **Sparse keyword vector** via TF-IDF feature hashing 3. **Qdrant hybrid query** with prefetch + RRF fusion (server-side) 4. **Deduplication** by name + entity type @@ -270,7 +270,7 @@ Tree-sitter support requires the Rust toolchain. Without it, only Elixir files a | Vector Type | Model | Purpose | |-------------|-------|---------| -| Dense (768-dim) | `nomic-embed-text` via Ollama | Semantic similarity | +| Dense (768-dim) | `embeddinggemma:300m` via Ollama (override with `OLLAMA_MODEL`) | Semantic similarity | | Sparse | TF-IDF feature hashing (ETS-backed IDF) | Keyword/exact match | | Fusion | Qdrant RRF | Combines both server-side | @@ -326,6 +326,12 @@ Run with `mix test --include performance`: ## Changelog +### v1.2.0 +- **Default embedding model is now `embeddinggemma:300m`** — `embedding_model.ex` `@default_model`, `docker-compose.yml`, `.env.example`, `config/config.exs` all updated. `OLLAMA_MODEL=nomic-embed-text` continues to work as an override. +- **Fix concurrency race in collection switch** — `reindex` now pre-checks `Indexer.busy?/0` before calling `ensure_collection_for_project`, so a rejected reindex no longer swaps the active Qdrant collection out from under in-flight Broadway batches (which previously caused hundreds of `404 Not found: Collection 'nexus_X' doesn't exist` errors). +- **Fix cold-start Ollama timeouts dropping chunks** — `embed_batch/1` now retries on `:timeout`/`:connect_timeout`/`:econnrefused` (up to 3 attempts, linear backoff); `recv_timeout` raised from 30s → 60s; `EmbeddingModel.warm_up/0` runs at supervisor start so the first real batch doesn't block on a cold model load. +- **Fix Docker healthcheck** — `code_nexus` healthcheck now uses `bash /dev/tcp` (the published image has no `curl`), so the container is correctly marked healthy. + ### v1.1.0 - **Multi-workspace Docker mounts** — `WORKSPACE_2`/`WORKSPACE_3` env vars mount additional host directories at `/workspace2`/`/workspace3`. Bare project names in `reindex` are resolved across all active mounts, so projects scattered across different host directories are all accessible without a shared parent. - 725 tests total diff --git a/config/config.exs b/config/config.exs index ac4acc7..128743c 100644 --- a/config/config.exs +++ b/config/config.exs @@ -21,7 +21,7 @@ config :logger, :console, format: "$time $metadata[$level] $message\n", metadata: [:request_id, :file, :line] -# Embedding model config (Ollama nomic-embed-text, 768-dim) +# Embedding model config (Ollama embeddinggemma:300m, 768-dim — alt: nomic-embed-text) config :elixir_nexus, ollama_url: "http://localhost:11434" diff --git a/config/test.exs b/config/test.exs index ac7dece..a2c1bec 100644 --- a/config/test.exs +++ b/config/test.exs @@ -5,3 +5,10 @@ config :logger, level: :warning # Test-specific configuration config :elixir_nexus, env: :test + +# Fast-fail Ollama in tests — no real model is available, retries just slow CI down. +# Production still uses the longer 60s timeout + 3 retries from EmbeddingModel. +config :elixir_nexus, + ollama_timeout: 1_000, + ollama_retry_attempts: 1, + ollama_retry_backoff_ms: 0 diff --git a/docs/DOCKERHUB.md b/docs/DOCKERHUB.md index b263225..1805d6c 100644 --- a/docs/DOCKERHUB.md +++ b/docs/DOCKERHUB.md @@ -4,7 +4,7 @@ Code intelligence MCP server — graph-powered semantic search, call graph trave ## Quick Start -**Prerequisites:** Ollama running on the host with `nomic-embed-text` pulled (`ollama pull nomic-embed-text`). +**Prerequisites:** Ollama running on the host with `embeddinggemma:300m` pulled (`ollama pull embeddinggemma:300m`). To use the older default instead, set `OLLAMA_MODEL=nomic-embed-text` and pull that model. ```bash WORKSPACE=~/Documents docker-compose up -d @@ -98,7 +98,7 @@ Add to your project's `.mcp.json`: | `QDRANT_URL` | `http://localhost:6333` | Qdrant vector DB URL | | `MCP_HTTP_PORT` | _(unset)_ | Set to enable MCP Streamable HTTP transport | | `OLLAMA_URL` | `http://localhost:11434` | Ollama API URL (use `http://host.docker.internal:11434` in Docker) | -| `OLLAMA_MODEL` | `nomic-embed-text` | Ollama embedding model name | +| `OLLAMA_MODEL` | `embeddinggemma:300m` | Ollama embedding model name (768-dim) | | `WORKSPACE_HOST` | _(unset)_ | Host path mapped to `/workspace` (for path translation) | | `WORKSPACE_HOST_2` | _(unset)_ | Host path mapped to `/workspace2` | | `WORKSPACE_HOST_3` | _(unset)_ | Host path mapped to `/workspace3` | @@ -110,7 +110,7 @@ Add to your project's `.mcp.json`: - **Elixir/Phoenix** app with Broadway-based indexing pipeline - **Tree-sitter** (Rust NIF) for AST parsing across all supported languages -- **Ollama nomic-embed-text** for 768-dim dense semantic embeddings +- **Ollama embeddinggemma:300m** for 768-dim dense semantic embeddings (configurable via `OLLAMA_MODEL`) - **TF-IDF** sparse vectors with ETS-backed vocabulary for hybrid search - **Qdrant** for vector storage and hybrid search (RRF fusion) - **ExMCP** for MCP protocol (stdio + Streamable HTTP) diff --git a/lib/elixir_nexus/application.ex b/lib/elixir_nexus/application.ex index 0eb5819..5f3b365 100644 --- a/lib/elixir_nexus/application.ex +++ b/lib/elixir_nexus/application.ex @@ -46,6 +46,11 @@ defmodule ElixirNexus.Application do opts = [strategy: :rest_for_one, name: ElixirNexus.Supervisor] {:ok, pid} = Supervisor.start_link(children, opts) + # Warm up Ollama so the first real embedding batch doesn't block on cold model load + if Mix.env() != :test do + ElixirNexus.EmbeddingModel.warm_up() + end + # Start MCP HTTP server if MCP_HTTP_PORT is set (Docker mode) if mcp_http_port = System.get_env("MCP_HTTP_PORT") do port = String.to_integer(mcp_http_port) diff --git a/lib/elixir_nexus/embedding_model.ex b/lib/elixir_nexus/embedding_model.ex index f691f30..2d1c575 100644 --- a/lib/elixir_nexus/embedding_model.ex +++ b/lib/elixir_nexus/embedding_model.ex @@ -1,12 +1,14 @@ defmodule ElixirNexus.EmbeddingModel do @moduledoc """ - Dense embedding via Ollama nomic-embed-text (768-dim). + Dense embedding via Ollama (default: embeddinggemma:300m, 768-dim). Stateless HTTP client — no GenServer or supervision needed. """ require Logger - @default_model "nomic-embed-text" - @timeout 30_000 + @default_model "embeddinggemma:300m" + @default_timeout 60_000 + @default_retry_attempts 3 + @default_retry_backoff_ms 1_000 defp ollama_url do System.get_env("OLLAMA_URL") || Application.get_env(:elixir_nexus, :ollama_url, "http://localhost:11434") @@ -16,6 +18,10 @@ defmodule ElixirNexus.EmbeddingModel do System.get_env("OLLAMA_MODEL") || Application.get_env(:elixir_nexus, :ollama_model, @default_model) end + defp ollama_timeout, do: Application.get_env(:elixir_nexus, :ollama_timeout, @default_timeout) + defp retry_attempts, do: Application.get_env(:elixir_nexus, :ollama_retry_attempts, @default_retry_attempts) + defp retry_backoff_ms, do: Application.get_env(:elixir_nexus, :ollama_retry_backoff_ms, @default_retry_backoff_ms) + @doc "Embed a single text. Returns {:ok, [float]} or {:error, reason}." def embed(text) when is_binary(text) do case embed_batch([text]) do @@ -24,12 +30,21 @@ defmodule ElixirNexus.EmbeddingModel do end end - @doc "Embed a batch of texts. Returns {:ok, [[float]]} or {:error, reason}." + @doc """ + Embed a batch of texts. Returns {:ok, [[float]]} or {:error, reason}. + Retries on transient errors (timeout, connection refused) with linear backoff — + covers Ollama cold-start when the model is loading. Retry count and timeout + are configurable via Application env (`:ollama_retry_attempts`, `:ollama_timeout`). + """ def embed_batch(texts) when is_list(texts) do + do_embed_batch(texts, 1) + end + + defp do_embed_batch(texts, attempt) do url = "#{ollama_url()}/api/embed" body = Jason.encode!(%{model: ollama_model(), input: texts}) - case HTTPoison.post(url, body, [{"Content-Type", "application/json"}], recv_timeout: @timeout) do + case HTTPoison.post(url, body, [{"Content-Type", "application/json"}], recv_timeout: ollama_timeout()) do {:ok, %{status_code: 200, body: resp_body}} -> case Jason.decode(resp_body) do {:ok, %{"embeddings" => embeddings}} -> @@ -47,12 +62,40 @@ defmodule ElixirNexus.EmbeddingModel do Logger.warning("Ollama returned #{code}: #{resp_body}") {:error, {:http_error, code}} + {:error, %HTTPoison.Error{reason: reason}} + when reason in [:timeout, :connect_timeout, :econnrefused] -> + if attempt < retry_attempts() do + backoff = retry_backoff_ms() * attempt + Logger.info("Ollama #{reason} on attempt #{attempt}/#{retry_attempts()}, retrying in #{backoff}ms") + Process.sleep(backoff) + do_embed_batch(texts, attempt + 1) + else + Logger.warning("Ollama connection failed: #{inspect(reason)}") + {:error, {:connection_failed, reason}} + end + {:error, %HTTPoison.Error{reason: reason}} -> Logger.warning("Ollama connection failed: #{inspect(reason)}") {:error, {:connection_failed, reason}} end end + @doc """ + Issue a tiny embed request to force Ollama to load the model into memory. + Called at supervisor start so the first real indexing batch doesn't block on cold load. + """ + def warm_up do + Task.start(fn -> + case embed("warmup") do + {:ok, _} -> + Logger.info("Ollama warm-up succeeded for model #{ollama_model()}") + + {:error, reason} -> + Logger.warning("Ollama warm-up failed: #{inspect(reason)} (will retry on first real request)") + end + end) + end + @doc "Check if Ollama is reachable and the model is available." def available? do url = "#{ollama_url()}/api/tags" diff --git a/lib/elixir_nexus/indexer.ex b/lib/elixir_nexus/indexer.ex index 3b2445a..25c3e29 100644 --- a/lib/elixir_nexus/indexer.ex +++ b/lib/elixir_nexus/indexer.ex @@ -36,6 +36,11 @@ defmodule ElixirNexus.Indexer do GenServer.call(__MODULE__, :status) end + @doc "Returns true when an indexing job is currently running." + def busy? do + status().status == :indexing + end + def search_chunks(query, limit \\ 10) do {:ok, ChunkCache.search(query, limit)} end diff --git a/lib/elixir_nexus/mcp_server.ex b/lib/elixir_nexus/mcp_server.ex index c5edc78..74c082c 100644 --- a/lib/elixir_nexus/mcp_server.ex +++ b/lib/elixir_nexus/mcp_server.ex @@ -309,49 +309,54 @@ defmodule ElixirNexus.MCPServer do {:ok, index_root, display_path} -> dirs = ElixirNexus.IndexingHelpers.detect_indexable_dirs(index_root) - if dirs == [] do - {:error, - "No indexable source directories found at '#{display_path}'." <> - PathResolution.workspace_hint(), state} - else - IndexManagement.ensure_collection_for_project(index_root) - Logger.info("Indexing project at #{index_root} (requested: #{display_path}), directories: #{inspect(dirs)}") - - case ElixirNexus.Indexer.index_directories(dirs) do - {:ok, status} -> - # File watching is best-effort — don't crash if it fails (e.g. in Docker without inotify) - try do - ElixirNexus.FileWatcher.unwatch_all() - - Enum.each(dirs, fn dir -> - case ElixirNexus.FileWatcher.watch_directory(dir) do - {:ok, _pid} -> :ok - {:error, reason} -> Logger.warning("Could not watch #{dir}: #{inspect(reason)}") - end - end) - rescue - e -> Logger.warning("File watcher setup failed: #{inspect(e)}") - end - - result = - %{ - indexed_files: status.indexed_files, - total_chunks: status.total_chunks, - directories: dirs, - project_path: display_path - } - |> PathResolution.maybe_add_default_path_warning(path_arg, display_path, state) - - Application.put_env(:elixir_nexus, :current_project_path, display_path) - - ResponseFormat.json_reply( - result, - state |> Map.put(:indexed_dirs, dirs) |> Map.put(:project_path, display_path) - ) - - {:error, reason} -> - {:error, "Reindex failed: #{inspect(reason)}", state} - end + cond do + dirs == [] -> + {:error, + "No indexable source directories found at '#{display_path}'." <> + PathResolution.workspace_hint(), state} + + ElixirNexus.Indexer.busy?() -> + {:error, "Reindex failed: :indexing_in_progress", state} + + true -> + IndexManagement.ensure_collection_for_project(index_root) + Logger.info("Indexing project at #{index_root} (requested: #{display_path}), directories: #{inspect(dirs)}") + + case ElixirNexus.Indexer.index_directories(dirs) do + {:ok, status} -> + # File watching is best-effort — don't crash if it fails (e.g. in Docker without inotify) + try do + ElixirNexus.FileWatcher.unwatch_all() + + Enum.each(dirs, fn dir -> + case ElixirNexus.FileWatcher.watch_directory(dir) do + {:ok, _pid} -> :ok + {:error, reason} -> Logger.warning("Could not watch #{dir}: #{inspect(reason)}") + end + end) + rescue + e -> Logger.warning("File watcher setup failed: #{inspect(e)}") + end + + result = + %{ + indexed_files: status.indexed_files, + total_chunks: status.total_chunks, + directories: dirs, + project_path: display_path + } + |> PathResolution.maybe_add_default_path_warning(path_arg, display_path, state) + + Application.put_env(:elixir_nexus, :current_project_path, display_path) + + ResponseFormat.json_reply( + result, + state |> Map.put(:indexed_dirs, dirs) |> Map.put(:project_path, display_path) + ) + + {:error, reason} -> + {:error, "Reindex failed: #{inspect(reason)}", state} + end end {:error, message} -> diff --git a/mix.exs b/mix.exs index 8690ad0..176c91f 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule ElixirNexus.MixProject do def project do [ app: :elixir_nexus, - version: "1.1.0", + version: "1.2.0", elixir: "~> 1.14", description: "Code intelligence MCP server — graph-powered semantic search, call graph traversal, and impact analysis for any codebase",