Skip to content

feat: add Ollama embedding provider + unify provider selection (AGE-1)#28

Merged
saldestechnology merged 2 commits into
mainfrom
worktree-age-1-ollama
Jul 11, 2026
Merged

feat: add Ollama embedding provider + unify provider selection (AGE-1)#28
saldestechnology merged 2 commits into
mainfrom
worktree-age-1-ollama

Conversation

@saldestechnology

Copy link
Copy Markdown
Collaborator

Implements AGE-1. Adds Ollama as a third embedding backend (alongside fastembed/local and OpenAI), so users get high-quality embeddings that run fully offline and free — and makes real end-to-end ctx smart relevance testing viable (the fastembed model can't run deterministically in CI; a local Ollama can).

What's new

  • OllamaProvider (src/embeddings/ollama.rs) → Ollama /api/embed.
    • Host OLLAMA_HOST (default http://localhost:11434, bare host:port normalized), model OLLAMA_EMBED_MODEL (default nomic-embed-text), optional OLLAMA_API_KEY bearer for remote hosts.
    • reqwest client + timeouts + retry/backoff mirroring openai.rs; actionable errors when the daemon is down or the model isn't pulled (ollama pull …).
  • Dynamic dimension — Ollama's dimension is model-dependent (nomic-embed-text 768, qwen3-embedding:8b 4096, …), so it's probed from the model on construction (sync from_env, plus from_env_async for the MCP runtime), not a compile-time constant.
  • Unified provider selection — replaced the use_openai: bool duplicated across ~6 call sites with a Provider enum + one build_provider factory and a --provider <local|openai|ollama> flag on embed, semantic, smart, similar, plus a provider field on the MCP smart_context tool. --openai kept as a deprecated alias.
  • Mismatch detection — a shared warn_index_mismatch warns when the query provider/dimension differs from the index (different providers/models occupy different vector spaces → re-embed).

Incidental fix

ctx embed's --force claimed -f, colliding with the global -f/--format. This only trips clap's debug-build assertion, so no prior release invocation hit it — but the new debug test surfaced it. Dropped the short (other subcommands' --force are already long-only; the watch commands use --force).

Tests + docs

  • Unit tests: host/model resolution + /api/embed response parsing (success, dimension mismatch, model-not-found, empty).
  • tests/ollama_smart_e2e.rs — a real ctx smart --provider ollama relevance test, gated behind CTX_TEST_OLLAMA=1 + a daemon reachability probe, so it's a no-op in normal CI (no charges, no downloads). Asserts relative ranking (not vectors) so it survives model drift. Verified locally against a live daemon with qwen3-embedding:8b: ctx smart "parse solidity contract source into a syntax tree" selects src/parser.rs above the off-topic files.
  • README + docs site (configuration, code-intelligence) document the providers and env vars.

Verification

All gates green: cargo fmt --check, cargo clippy --all-targets -D warnings, full suite (298 lib incl. new Ollama unit tests + all integration suites). The gated e2e passes live and skips cleanly when CTX_TEST_OLLAMA is unset.

Acceptance criteria (AGE-1)

  • OllamaProvider implements EmbeddingProvider with configurable host + model
  • Dimension determined dynamically from the model
  • embed / similar / smart / MCP analysis can all use --provider ollama
  • Provider selection unified into a single factory (no scattered use_openai)
  • Provider/dimension mismatch against an existing index is detected & surfaced
  • Unit tests for request/response parsing + gated integration test behind a running Ollama
  • Docs updated (embeddings/semantic-search section)

Run the e2e locally: CTX_TEST_OLLAMA=1 cargo test --test ollama_smart_e2e -- --nocapture (set CTX_TEST_OLLAMA_MODEL to override the default qwen3-embedding:8b).

Adds Ollama as a third embedding backend alongside fastembed (local) and
OpenAI, so users get high-quality embeddings that run fully offline and free.

- New `src/embeddings/ollama.rs` (`OllamaProvider`) talking to Ollama's
  `/api/embed`. Host from `OLLAMA_HOST` (default http://localhost:11434, bare
  host:port normalized), model from `OLLAMA_EMBED_MODEL` (default
  nomic-embed-text), optional `OLLAMA_API_KEY` bearer for remote hosts. Mirrors
  the openai.rs reqwest client + timeout + retry/backoff. Actionable errors when
  the daemon is down or the model isn't pulled.
- Dynamic dimension: Ollama's dimension is model-dependent, so it is probed from
  the model on construction (sync `from_env`, plus `from_env_async` for the MCP
  async runtime) rather than a compile-time constant.
- Provider-selection refactor: replaced the `use_openai: bool` duplicated across
  ~6 call sites with a `Provider` enum + single `build_provider` factory and a
  `--provider <local|openai|ollama>` flag on `embed`, `semantic`, `smart`, and
  `similar`, plus a `provider` field on the MCP smart_context tool. `--openai`
  is kept as a deprecated alias.
- Provider/dimension mismatch against an existing index is detected and warned
  via a shared `warn_index_mismatch` (embeddings live in different vector spaces
  per provider/model, so switching requires re-embedding).
- Fix a latent CLI bug surfaced by the new debug test: `ctx embed`'s `--force`
  claimed `-f`, which collides with the global `-f/--format`; dropped the short
  (other subcommands' `--force` are already long-only). This only asserted in
  debug builds, so no prior (release) invocation hit it.

Tests + docs:
- Unit tests for host/model resolution and `/api/embed` response parsing
  (success, dimension mismatch, model-not-found, empty).
- `tests/ollama_smart_e2e.rs`: a real end-to-end `ctx smart --provider ollama`
  relevance test, gated behind `CTX_TEST_OLLAMA=1` + a reachability probe so it
  is a no-op in normal CI. Verified locally against a live daemon with
  qwen3-embedding:8b — `ctx smart` selects the on-topic file over off-topic ones.
- Documented the providers + env vars in README and the docs site.
…rovider

Adds an optional, committed `.ctx/config.toml` so a project can set defaults
once instead of passing flags/env vars on every command. Currently it configures
the embedding backend:

    [embedding]
    provider = "ollama"            # local | openai | ollama
    model = "qwen3-embedding:8b"   # Ollama/OpenAI model
    # host = "http://localhost:11434"

- New `src/config.rs` (`CtxConfig`/`EmbeddingConfig`) loaded from
  `<root>/.ctx/config.toml`; missing or malformed files fall back to defaults
  (never fatal — config is optional), and unknown keys are ignored so older
  binaries tolerate newer files.
- Resolution precedence is always CLI flag > env var > config > built-in
  default, so the file never overrides an explicit request:
  - provider: `Provider::resolve` gains the config default as the lowest-priority
    source (wired in main.rs and the MCP tool);
  - Ollama model/host: `OLLAMA_EMBED_MODEL`/`OLLAMA_HOST` still win over the
    config's `model`/`host`, threaded through `build_provider` → `OllamaProvider::from_config`.
- `.gitignore`: `.ctx/` stays ignored but `.ctx/config.toml` is kept tracked so
  the project default is shared with the team.

Tests: config parsing (present/absent/malformed/unknown-keys) and the resolution
precedence. Docs: documented `.ctx/config.toml` in the configuration guide.
@saldestechnology

Copy link
Copy Markdown
Collaborator Author

Added project config support: an optional committed .ctx/config.toml ([embedding] provider/model/host) so a repo can set a default embedding backend without flags. Precedence is CLI flag > env > config > default; .gitignore keeps config.toml tracked while ignoring the rest of .ctx/. Verified end-to-end: with provider = "ollama" in the file, ctx semantic/smart use ollama with no --provider flag.

@saldestechnology
saldestechnology merged commit 8e69700 into main Jul 11, 2026
5 of 7 checks passed
saldestechnology added a commit that referenced this pull request Jul 11, 2026
Sets `.ctx/config.toml` so `ctx embed`/`semantic`/`smart`/`similar` use the
local Ollama backend (qwen3-embedding:8b) by default here, without needing
`--provider`/env vars. The index has been re-embedded with this model.

Requires ctx built from #28 (Ollama provider + config support); older binaries
ignore the file and fall back to the local fastembed default.
@saldestechnology
saldestechnology deleted the worktree-age-1-ollama branch July 11, 2026 13:04
saldestechnology added a commit that referenced this pull request Jul 11, 2026
…atures

The project-config load added in #28 was named `config`, shadowing the
`SmartConfig config` above it, so `smart_context_with_embedding(..., config)`
passed the wrong type. Only compiled under the `mcp` feature, so the default
build (and macOS CI) missed it; `--all-features` failed to compile. Rename the
project config to `project_config`. (Latent on main from #28; this fix lands
there via this PR.)
saldestechnology added a commit that referenced this pull request Jul 11, 2026
…rt (#25)

* feat: lexical path boost + oversized-top budget guarantee for ctx smart

Two residual `ctx smart` misses remained after #18 (which made selection
deterministic and semantic-first), each with a distinct cause:

- A candidate whose path literally contains a task word could still be
  outranked by the semantic matches and squeezed out by the token budget
  (e.g. `embeddings/openai.rs` for "…openai" — present only via a call-graph
  edge). A lexical signal fixes this.
- The single most-relevant file could exceed the whole budget and be dropped
  by greedy first-fit, which then backfilled with smaller, less-relevant
  files (e.g. `parser/solidity.rs`, 9074 tokens > the 8000 budget, was
  already rank 1 yet vanished). A budget-policy fix is needed.

Part A — lexical path boost:
- New shared `utils::lexical_tokens`: lowercases, splits on non-alphanumeric
  and camelCase boundaries, drops length-≤-1 tokens and a small stopword set.
- `FileSelection` gains `lexical_score` = count of distinct task tokens that
  appear in the file's PATH (path only, not symbol names: matching identifiers
  would let ubiquitous names like `ctx` — a test helper across `tests/*_cli.rs`
  — score a hit on nearly every file and drown out the on-topic one).
- `rank_files` treats a lexical hit as top-tier alongside semantic matches and
  orders lexical hits first within the tier — surfacing the on-topic file
  without any tunable float weight. Determinism (path tie-break) is preserved.

Part B — budget guarantee:
- `select_with_guaranteed_top` always includes the rank-1 file even when it
  alone exceeds the budget, then first-fits the remainder (reusing
  `select_by_token_budget`). The handler prints a one-line stderr note when the
  top file is oversized.

Adds unit tests for the tokenizer, lexical promotion/ordering, path-only
scoring (asserting `ctx` in a symbol name does not match), and the oversized-
top budget policy. #18's determinism/tiering tests still pass with
`lexical_score = 0`.

Verified end-to-end: "…openai" now selects `embeddings/openai.rs` at rank 1;
"parse solidity contracts" includes `parser/solidity.rs` with the oversize
note; "add a new output format to ctx sql" keeps `src/commands/sql.rs` at
rank 1 and stays deterministic across runs.

* test: deterministic offline regression guard for ctx smart ranking

Adds tests/smart_relevance.rs — a library-level integration test that guards
the smart file-selection ranking against regressions without needing the
fastembed model (which downloads ~90MB and is not byte-stable across arches,
so it cannot run in CI).

It hand-builds a tiny on-disk index (files + symbols + edges) and seeds exact
embedding vectors via the library API (Database::store_embedding), then calls
smart_context_with_embedding directly with a crafted query vector — exercising
the full seed -> call-graph expand -> lexical -> rank -> budget pipeline
deterministically and offline.

Two scenarios:
- path_match_beats_symbol_name_noise: a task token in a file's PATH must
  promote the on-topic file over a higher-semantic-scored file whose only match
  is a symbol NAME. This reproduces the `ctx` test-helper regression that was
  originally caught by hand. Verified as a real guard via mutation: reverting
  compute_lexical_score to also match symbol names makes this test fail.
- graph_only_path_match_is_promoted: a candidate reachable only through
  call-graph expansion whose path matches the task is surfaced to the top
  rather than being outranked by the semantic matches and dropped.

* fix: MCP analysis config variable shadowed SmartConfig under --all-features

The project-config load added in #28 was named `config`, shadowing the
`SmartConfig config` above it, so `smart_context_with_embedding(..., config)`
passed the wrong type. Only compiled under the `mcp` feature, so the default
build (and macOS CI) missed it; `--all-features` failed to compile. Rename the
project config to `project_config`. (Latent on main from #28; this fix lands
there via this PR.)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant