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 .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,9 @@ tmp/
.env
.env.*

# Generated files
.ctx/
# Generated files (index, embeddings) — but keep the committed project config
.ctx/*
!.ctx/config.toml
.fastembed_cache/

# AI specific
Expand Down
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,8 @@ ctx --max-tokens 8000 "src/**/*.rs"
```

Semantic search and `ctx smart`/`ctx similar` need embeddings first. Generate them with
`ctx embed` (a ~90 MB local model by default, or `--openai` with `OPENAI_API_KEY`). See
`ctx embed` — `--provider local` (default, a ~90 MB fastembed model), `--provider ollama`
(any local Ollama model, offline and free), or `--provider openai` (needs `OPENAI_API_KEY`). See
[Index & embed first](https://docs.agentis.tools/docs/guides/indexing).

### Govern: guardrails on what changes
Expand Down Expand Up @@ -253,15 +254,17 @@ token counting, and output formatting.
- **Tree-sitter** parses every supported language into symbols and relationship edges.
- **SQLite** (with FTS5 and `sqlite-vec`) is the persistent, single-file store.
- **DuckDB** runs the recursive graph and analytical queries (default-on; not available on Windows).
- **fastembed** generates local embeddings offline (all-MiniLM-L6-v2, 384-dim); OpenAI is optional.
- **fastembed** generates local embeddings offline (all-MiniLM-L6-v2, 384-dim); **Ollama** (any local model) and **OpenAI** are optional via `--provider`.

Indexing respects `.gitignore`, an optional `.contextignore`, and 170+ built-in patterns. See
[Configuration](https://docs.agentis.tools/docs/configuration) and
[Architecture](https://docs.agentis.tools/docs/architecture).

| Variable | Description |
|----------|-------------|
| `OPENAI_API_KEY` | Required for the `--openai` flag on `embed` / `semantic` / `smart` / `similar` |
| `OPENAI_API_KEY` | Required for `--provider openai` on `embed` / `semantic` / `smart` / `similar` |
| `OLLAMA_HOST` | Ollama server URL for `--provider ollama` (default `http://localhost:11434`) |
| `OLLAMA_EMBED_MODEL` | Ollama embedding model (default `nomic-embed-text`) |
| `GITHUB_TOKEN` | Optional for `review` (uses `gh` CLI auth by default) |
| `CTX_NO_UPDATE_CHECK` | Silence the passive "new release available" notice |

Expand Down
20 changes: 15 additions & 5 deletions docs/website/docs/code-intelligence.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,24 +211,34 @@ This finds symbols based on **meaning**, not just keywords. For example, "authen
ctx search "query" --limit 10 # Limit results
ctx search "query" --output json # JSON output

ctx semantic "query" --limit 20 # Semantic search with limit
ctx semantic "query" --output json # JSON output
ctx semantic "query" --openai # Use OpenAI embeddings
ctx semantic "query" --limit 20 # Semantic search with limit
ctx semantic "query" --output json # JSON output
ctx semantic "query" --provider ollama # Use an Ollama embedding model
```

## Generating Embeddings

### Basic Embedding Generation

Choose a backend with `--provider <local|openai|ollama>` (default `local`):

```bash
# Local embeddings (no API key required)
# Local embeddings (fastembed, no API key; ~90 MB model on first run)
ctx embed

# Ollama embeddings (fully offline, free; start `ollama serve` + pull a model)
ollama pull nomic-embed-text
ctx embed --provider ollama
OLLAMA_EMBED_MODEL=qwen3-embedding:8b ctx embed --provider ollama

# OpenAI embeddings (requires OPENAI_API_KEY)
export OPENAI_API_KEY=sk-...
ctx embed --openai
ctx embed --provider openai # `--openai` is a deprecated alias
```

Embeddings from different providers/models live in different vector spaces, so
switching providers requires re-embedding (`--force`); ctx warns on a mismatch.

This generates embeddings for all symbols. Embeddings are stored in SQLite and only need to be generated once (or when new symbols are added).

### Embedding Providers
Expand Down
51 changes: 50 additions & 1 deletion docs/website/docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -467,7 +467,56 @@ ctx uses minimal environment variables:

| Variable | Purpose | Required |
|----------|---------|----------|
| `OPENAI_API_KEY` | OpenAI embeddings via `ctx embed --openai` | Only for OpenAI provider |
| `OPENAI_API_KEY` | OpenAI embeddings via `ctx embed --provider openai` | Only for the OpenAI provider |
| `OLLAMA_HOST` | Ollama server URL (default `http://localhost:11434`) | Only for the Ollama provider |
| `OLLAMA_EMBED_MODEL` | Ollama embedding model (default `nomic-embed-text`) | Only for the Ollama provider |
| `OLLAMA_API_KEY` | Optional bearer token for a remote/authenticated Ollama host | No |

### Project config (`.ctx/config.toml`)

Per-project defaults live in an optional, **committed** `.ctx/config.toml` so a
team shares one setup instead of passing flags/env vars every time. Today it
configures the embedding backend:

```toml
[embedding]
provider = "ollama" # local (default) | openai | ollama
model = "qwen3-embedding:8b" # Ollama/OpenAI model name
# host = "http://localhost:11434" # Ollama only
```

Resolution is always **CLI flag > environment variable > `.ctx/config.toml` >
built-in default**, so the file never overrides an explicit request. `.ctx/` is
otherwise git-ignored; the repo's `.gitignore` keeps `config.toml` tracked.

### Embedding providers

`ctx embed`, `ctx semantic`, `ctx smart`, and `ctx similar` accept
`--provider <local|openai|ollama>` (or set a default in `.ctx/config.toml`):

- **`local`** (default) — [fastembed](https://github.com/Anush008/fastembed-rs)
`all-MiniLM-L6-v2`, 384-dim. Offline; downloads a ~90 MB model on first run.
- **`openai`** — `text-embedding-3-small`, 1536-dim. Requires `OPENAI_API_KEY`.
- **`ollama`** — any local [Ollama](https://ollama.com) embedding model
(`nomic-embed-text`, `mxbai-embed-large`, `qwen3-embedding:8b`, …). Fully
offline and free; dimension is derived from the model.

```bash
# Ollama (start the daemon and pull a model first)
ollama pull nomic-embed-text
ctx embed --provider ollama
ctx smart --provider ollama "add a new output format"

# A different model / remote host
OLLAMA_EMBED_MODEL=qwen3-embedding:8b ctx embed --provider ollama
OLLAMA_HOST=http://gpu-box:11434 ctx embed --provider ollama
```

> Embeddings from different providers/models occupy different vector spaces, so
> switching providers requires re-embedding (`ctx embed --provider … --force`).
> ctx warns when the query provider/dimension doesn't match the index.

`--openai` is still accepted as a deprecated alias for `--provider openai`.

### Setting OPENAI_API_KEY

Expand Down
29 changes: 24 additions & 5 deletions src/cli.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use clap::{Parser, Subcommand, ValueEnum};

use crate::commands::hotspots::HotspotBy;
use ctx::embeddings::Provider;

/// CLI output format (with clap integration).
#[derive(ValueEnum, Clone, Copy, Debug, Default, PartialEq)]
Expand Down Expand Up @@ -261,7 +262,9 @@ internal and unstable. Access is read-only and engine-hardened.
/// Generate embeddings for semantic search
Embed {
/// Force re-embedding of all symbols
#[arg(long, short)]
// No `short`: `-f` is the global `--format` flag (other subcommands'
// `--force` are also long-only). Use `--force`.
#[arg(long)]
force: bool,

/// Show verbose output
Expand All @@ -272,7 +275,11 @@ internal and unstable. Access is read-only and engine-hardened.
#[arg(long, default_value = "50")]
batch_size: usize,

/// Use OpenAI API instead of local model (requires OPENAI_API_KEY)
/// Embedding backend: local (default), openai, or ollama
#[arg(long, value_enum)]
provider: Option<Provider>,

/// Deprecated alias for `--provider openai` (requires OPENAI_API_KEY)
#[arg(long)]
openai: bool,

Expand All @@ -294,7 +301,11 @@ internal and unstable. Access is read-only and engine-hardened.
#[arg(long, default_value = "table")]
output: String,

/// Use OpenAI API instead of local model (requires OPENAI_API_KEY)
/// Embedding backend: local (default), openai, or ollama
#[arg(long, value_enum)]
provider: Option<Provider>,

/// Deprecated alias for `--provider openai` (requires OPENAI_API_KEY)
#[arg(long)]
openai: bool,
},
Expand All @@ -321,7 +332,11 @@ internal and unstable. Access is read-only and engine-hardened.
#[arg(long)]
keyword: bool,

/// Use OpenAI API instead of local model (requires OPENAI_API_KEY)
/// Embedding backend: local (default), openai, or ollama
#[arg(long, value_enum)]
provider: Option<Provider>,

/// Deprecated alias for `--provider openai` (requires OPENAI_API_KEY)
#[arg(long)]
openai: bool,
},
Expand Down Expand Up @@ -442,7 +457,11 @@ internal and unstable. Access is read-only and engine-hardened.
#[arg(long)]
dry_run: bool,

/// Use OpenAI API instead of local model (requires OPENAI_API_KEY)
/// Embedding backend: local (default), openai, or ollama
#[arg(long, value_enum)]
provider: Option<Provider>,

/// Deprecated alias for `--provider openai` (requires OPENAI_API_KEY)
#[arg(long)]
openai: bool,

Expand Down
91 changes: 22 additions & 69 deletions src/commands/embed.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,30 +5,26 @@
use std::env;
use std::time::Instant;

use ctx::embeddings::{self, EmbeddingProvider};
use ctx::embeddings::{self, Provider};
use ctx::error::Result;
use ctx::index;
use ctx::utils::{truncate_path, truncate_str};

/// Emit the one-time hint before the local model is (down)loaded.
fn local_model_hint(provider: Provider) {
if provider == Provider::Local {
eprintln!("Initializing local embedding model (first run downloads ~90MB)...");
}
}

/// Generate embeddings for all symbols.
pub fn run_embed(force: bool, verbose: bool, batch_size: usize, use_openai: bool) -> Result<()> {
pub fn run_embed(force: bool, verbose: bool, batch_size: usize, provider: Provider) -> Result<()> {
let root = env::current_dir()?;
let db = index::open_database(&root)?;

// Create provider based on flag
let provider: Box<dyn EmbeddingProvider> = if use_openai {
use embeddings::openai::OpenAIProvider;
let p = OpenAIProvider::from_env().map_err(|_| {
"OPENAI_API_KEY environment variable not set.\n\
Set it with: export OPENAI_API_KEY=sk-..."
})?;
Box::new(p)
} else {
use embeddings::local::LocalProvider;
eprintln!("Initializing local embedding model (first run downloads ~90MB)...");
let p = LocalProvider::new()?;
Box::new(p)
};
local_model_hint(provider);
let provider =
embeddings::build_provider(provider, &ctx::config::CtxConfig::load(&root).embedding)?;

if verbose {
println!(
Expand Down Expand Up @@ -95,7 +91,7 @@ pub fn run_embed(force: bool, verbose: bool, batch_size: usize, use_openai: bool
}

/// Watch for index changes and auto-embed new symbols.
pub fn run_embed_watch(verbose: bool, batch_size: usize, use_openai: bool) -> Result<()> {
pub fn run_embed_watch(verbose: bool, batch_size: usize, provider: Provider) -> Result<()> {
use notify::RecursiveMode;
use notify_debouncer_mini::{new_debouncer, DebouncedEventKind};
use std::sync::mpsc::channel;
Expand All @@ -105,20 +101,9 @@ pub fn run_embed_watch(verbose: bool, batch_size: usize, use_openai: bool) -> Re
let ctx_dir = root.join(".ctx");
let _db_path = ctx_dir.join("codebase.sqlite");

// Create provider based on flag
let provider: Box<dyn EmbeddingProvider> = if use_openai {
use embeddings::openai::OpenAIProvider;
let p = OpenAIProvider::from_env().map_err(|_| {
"OPENAI_API_KEY environment variable not set.\n\
Set it with: export OPENAI_API_KEY=sk-..."
})?;
Box::new(p)
} else {
use embeddings::local::LocalProvider;
eprintln!("Initializing local embedding model (first run downloads ~90MB)...");
let p = LocalProvider::new()?;
Box::new(p)
};
local_model_hint(provider);
let provider =
embeddings::build_provider(provider, &ctx::config::CtxConfig::load(&root).embedding)?;

println!(
"Using embedding provider: {} (dim={})",
Expand Down Expand Up @@ -231,7 +216,7 @@ pub fn run_embed_watch(verbose: bool, batch_size: usize, use_openai: bool) -> Re
}

/// Run semantic search using embeddings.
pub fn run_semantic(query: &str, limit: usize, output: &str, use_openai: bool) -> Result<()> {
pub fn run_semantic(query: &str, limit: usize, output: &str, provider: Provider) -> Result<()> {
let root = env::current_dir()?;
let db = index::open_database(&root)?;

Expand All @@ -245,44 +230,12 @@ pub fn run_semantic(query: &str, limit: usize, output: &str, use_openai: bool) -
return Ok(());
}

// Create provider based on flag
let provider: Box<dyn EmbeddingProvider> = if use_openai {
use embeddings::openai::OpenAIProvider;
let p = OpenAIProvider::from_env().map_err(|_| {
"OPENAI_API_KEY environment variable not set.\n\
Set it with: export OPENAI_API_KEY=sk-..."
})?;
Box::new(p)
} else {
use embeddings::local::LocalProvider;
let p = LocalProvider::new()?;
Box::new(p)
};
local_model_hint(provider);
let provider =
embeddings::build_provider(provider, &ctx::config::CtxConfig::load(&root).embedding)?;

// Check for embedding dimension mismatch
let query_dim = provider.dimension();
if let Ok(metadata) = db.get_embedding_metadata() {
for (stored_provider, _model, stored_dim, count) in &metadata {
let stored_dim = *stored_dim as usize;
if stored_dim != query_dim {
eprintln!("Warning: Embedding dimension mismatch detected!");
eprintln!(
" Stored: {} embeddings from '{}' with dimension {}",
count, stored_provider, stored_dim
);
eprintln!(
" Query: Using '{}' with dimension {}",
provider.name(),
query_dim
);
eprintln!(
" Results may be inaccurate. Re-run 'ctx embed{}' to regenerate embeddings.",
if use_openai { " --openai" } else { "" }
);
eprintln!();
}
}
}
// Warn if the query provider/dimension differs from the index.
embeddings::warn_index_mismatch(&db, provider.as_ref());

// Embed the query
let query_embedding = provider.embed(query)?;
Expand Down
22 changes: 5 additions & 17 deletions src/commands/similar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
use std::env;

use ctx::db::{Database, Symbol, SymbolKind};
use ctx::embeddings::{self, Embedding, EmbeddingProvider};
use ctx::embeddings::{self, Embedding, Provider};
use ctx::error::{CtxError, Result};
use ctx::exit::Outcome;
use ctx::index;
Expand Down Expand Up @@ -36,7 +36,7 @@ pub fn run_similar(
query: &str,
limit: usize,
keyword: bool,
use_openai: bool,
provider: Provider,
json: bool,
) -> Result<Outcome> {
let root = env::current_dir()?;
Expand All @@ -46,7 +46,9 @@ pub fn run_similar(
(keyword_hits(&db, query, limit)?, "keyword")
} else {
ensure_embeddings(&db)?;
let provider = build_provider(use_openai)?;
let provider =
embeddings::build_provider(provider, &ctx::config::CtxConfig::load(&root).embedding)?;
embeddings::warn_index_mismatch(&db, provider.as_ref());
let query_embedding = provider.embed(query)?;
(semantic_hits(&db, &query_embedding, limit)?, "semantic")
};
Expand Down Expand Up @@ -78,20 +80,6 @@ fn ensure_embeddings(db: &Database) -> Result<()> {
Ok(())
}

/// Build the embedding provider (local fastembed by default, OpenAI on flag).
fn build_provider(use_openai: bool) -> Result<Box<dyn EmbeddingProvider>> {
if use_openai {
let p = embeddings::openai::OpenAIProvider::from_env().map_err(|_| {
"OPENAI_API_KEY environment variable not set.\n\
Set it with: export OPENAI_API_KEY=sk-..."
})?;
Ok(Box::new(p))
} else {
let p = embeddings::local::LocalProvider::new()?;
Ok(Box::new(p))
}
}

/// Is this symbol kind in scope for `ctx similar`?
fn is_callable(kind: SymbolKind) -> bool {
matches!(kind, SymbolKind::Function | SymbolKind::Method)
Expand Down
Loading
Loading