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",