Skip to content
Merged
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
5 changes: 3 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 |
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ graph TB

subgraph Indexing["Indexing Pipeline (Broadway)"]
CH["Chunker<br/>semantic chunks"]
OL["Ollama nomic-embed-text<br/>768-dim dense vectors"]
OL["Ollama embeddinggemma:300m<br/>768-dim dense vectors"]
TFIDF["TF-IDF<br/>sparse keyword vectors"]
end

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 |

Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
7 changes: 7 additions & 0 deletions config/test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 3 additions & 3 deletions docs/DOCKERHUB.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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` |
Expand All @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions lib/elixir_nexus/application.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
53 changes: 48 additions & 5 deletions lib/elixir_nexus/embedding_model.ex
Original file line number Diff line number Diff line change
@@ -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")
Expand All @@ -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
Expand All @@ -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}} ->
Expand All @@ -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"
Expand Down
5 changes: 5 additions & 0 deletions lib/elixir_nexus/indexer.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
91 changes: 48 additions & 43 deletions lib/elixir_nexus/mcp_server.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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} ->
Expand Down
2 changes: 1 addition & 1 deletion mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading