Skip to content
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,20 @@ finalized in place with a date — no renaming/migration step needed.

- **Claude Code web-guard hook is topic/mount-scoped.** Only queries about the mounted products reach the web guard, and the retry cache is keyed per mount.

- **`codesearch serve --model X` sets the default model for newly created indexes.** Previously `--model` was inert on `serve` (each repo's query model comes from its own `metadata.json`); now it is the model a repo is indexed with when it is added through serve without an explicit model — `POST /repos`, including the `codesearch index add` path delegated to a running serve — so an operator can make a non-default model the norm without passing `--model` on every add. An index that already records its own model is never overridden (an explicit `model` in the request still wins). Serve reports the default in `GET /status` as `default_model` and at startup. It is deliberately **not** the query fallback for a repo whose `metadata.json` records no model: a legacy index built before the model-recording contract is queried with the built-in 384-dim default, and the search response carries a warning naming the assumed model and the re-index command. Following the serve default there would break a legacy repo the instant an operator set `--model` to a model of another dimension (a 384-dim index queried with a 768-dim model fails), and degrade it silently for a same-dimension model.

### Fixed

- **`status` no longer reports `ready` for an index that cannot be searched.** Readiness keyed only off `total_chunks`, so a repo mid-rebuild — chunks inserted, `build_index()` not yet run — reported `status: "ready"` / "Index is ready for searching." while every search failed with `Index not built. Call build_index() after inserting chunks.` Both the single-repo and group status paths now require the vector index (`stats.indexed`) to be built before reporting `ready`, otherwise they report `building` with a message that says the vector index is not built. A store that failed to report stats is still surfaced separately (degraded-ready with `warnings`), never misread as "not built".

- **Adding a repo with `--model` now creates the index at that model's dimension.** `POST /repos` — the path `codesearch index add --model …` delegates to when serve is running — opened the store with the default 384-dim dimension and applied the model override only to `metadata.json` afterwards. The background reindex then embedded 768-dim EmbeddingGemma vectors into a 384-dim store and indexed nothing: the `.codesearch.db` directory existed, `stats` showed the new dimension, and no files were indexed. The store is now opened at the override's dimension.

- **The CLI no longer downgrades a non-default index to the 384-dim default.** `codesearch index` resolved its embedding model as `--model`-or-`ModelType::default()` and never consulted the model recorded in the index's `metadata.json`, so re-indexing a repo built with EmbeddingGemma embedded 384-dim MiniLM vectors against a 768-dim store (and `FileMetaStore` logged "Model changed, full re-index required", wiping the file metadata). The CLI now resolves the recorded model through the same helper the serve/watcher paths use; an explicit `--model` that disagrees is rejected with a pointer to `--force`. Relatedly, `codesearch stats`, `get_db_stats` and the repo listing opened the vector store with a hardcoded 384, so `Dimensions:` always read 384 for every index — they now read the recorded dimensions.

- **`status(kind="index")` reports the routed repo's model, not the service default.** The `model` field was the service's own model (the hardcoded default in serve mode) while `dimensions` came from live store stats, so every repo read `minilm-l6-q` regardless of what it was indexed with. It now resolves per repo for `project=`, and reports the common model — or `mixed` — for a group.

- **Serve mode embeds each query with its target repo's indexed model.** The multi-repo MCP service built its shared embedder from `ModelType::default()` (384-dim MiniLM) and ignored both `--model` and the `model_short_name` each index records, so every semantic query against an index rebuilt with a 768-dim model (EmbeddingGemma) failed with `Query embedding dimension mismatch: expected 768, got 384` — and a same-dimension mismatch would have silently compared incomparable vector spaces. The service now resolves the model per routed repo (the same `metadata.json` contract the indexing path already followed) through a per-model `EmbeddingServicePool`; group fan-out embeds the query once per distinct model and searches each store with its own. (See the `serve --model` default for newly created indexes under **Added**.)

- **Persisted helper-exit warnings are platform-stable — Linux CI green again.** `ExitStatus`'s `Display` renders `exit code: N` on Windows but `exit status: N` on Unix, so the non-zero-exit warning the TypeScript symbol indexer persists into its meta table disagreed with its own regression test on Linux, failing `test-linux`/`csharp-integration-tests` deterministically since #238. A shared `exit_status_text()` renders `exit code: N` on every platform (signal-terminated processes fall back to the platform string), applied to the persisted TS warning and the C#/TS helper log lines, pinned by a cross-platform unit test.

- **C# canonical symbol keys no longer collapse distinct declarations.** Generic arity (``M`1``), the full containing-type chain and fully qualified parameter types are part of the key again, so overloads that previously shared one identity keep their own. The index version is bumped to 2.0 with a key-format stamp in the index meta: a stale-format index reports as absent and rebuilds once on upgrade (todo #139).
Expand Down
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,21 @@ spaces are model-specific. Keep the same model selected for later indexing runs.
Search rejects a `--model` value that differs from the indexed model and points
to the required `--force` rebuild instead of mixing incompatible vector spaces.

In **serve** mode the model is resolved **per repository**, from each index's own
metadata, not from a hub-wide setting: a hub may hold indexes built with
different models, and every query is embedded with the model of the repo it
targets (mixed-model groups are fine). `codesearch serve --model <name>` sets a
**default for newly created indexes**: a repo added without an explicit model
(e.g. `codesearch index add` with no `--model`, delegated to serve) is indexed
with it, and it is reported in `GET /status` as `default_model`. It never
overrides an index that already records its own model — to change an existing
repo's model, re-index that repo
(`codesearch --model <name> index <path> --force`) and restart serve. A repo
whose `metadata.json` records no model (a legacy index built before the
recording contract) is queried with the built-in 384-dim default rather than the
serve default, and the search response carries a warning naming the assumed model
and the re-index command.

## MCP Configuration

codesearch connects to AI agents via MCP. Two modes:
Expand Down
13 changes: 12 additions & 1 deletion src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,9 @@ pub struct Cli {
#[arg(long, global = true)]
pub store: Option<String>,

/// Embedding model to use (e.g., bge-small, jina-code, embeddinggemma-q4)
/// Embedding model to use (e.g., bge-small, jina-code, embeddinggemma-q4).
/// On `serve`, this is the default for newly created indexes; each repo's
/// existing index keeps the model recorded in its own metadata.
#[arg(long, global = true)]
pub model: Option<String>,
}
Expand Down Expand Up @@ -1222,10 +1224,19 @@ pub async fn run(cancel_token: CancellationToken) -> Result<()> {
if let Err(e) = crate::logger::init_serve_logger(log_level, effective_quiet) {
eprintln!("Warning: failed to initialize serve logger: {}", e);
}
// `--model` is a global flag inherited by every subcommand. On
// `serve` it sets the serve-wide default for newly created
// indexes — each repo's queries still use the model recorded
// in its own index metadata (a hub may mix models), so this
// never overrides an existing index.
if let Some(mt) = model_type {
warn_if_heavier_model(mt);
}
crate::serve::run_serve(
host,
port,
register,
model_type,
no_tui,
keep_warm_url,
idle_suspend_secs,
Expand Down
20 changes: 19 additions & 1 deletion src/embed/embedder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use ort::ep::CPU;
use crate::file::Language;

/// Available embedding models
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum ModelType {
// === MiniLM Family ===
/// All-MiniLM-L6-v2 - 384 dimensions, fast and efficient
Expand Down Expand Up @@ -242,6 +242,24 @@ impl ModelType {
}
}

/// Resolve the embedding model recorded in an index's `metadata.json`.
///
/// The reader counterpart to [`Self::write_metadata_fields`]. Returns `None`
/// when the file is missing/unreadable, carries no `model_short_name`, or
/// names a model this build does not know — callers fall back to
/// [`ModelType::default`]. Every query path MUST resolve the model per
/// target index through here (instead of assuming the default): embedding a
/// query with any model other than the one the index was built with either
/// fails with a dimension mismatch or silently compares incomparable vector
/// spaces. Multi-repo serve mode is where this matters most, because one
/// hub can hold indexes built with different models.
pub fn from_index_metadata(db_path: &std::path::Path) -> Option<Self> {
let content = std::fs::read_to_string(db_path.join("metadata.json")).ok()?;
let json: serde_json::Value = serde_json::from_str(&content).ok()?;
let name = json.get("model_short_name").and_then(|v| v.as_str())?;
Self::parse(name)
}

pub fn prepare_query(&self, text: &str) -> String {
match self {
Self::EmbeddingGemma300MQ4 => format!("task: search result | query: {text}"),
Expand Down
89 changes: 89 additions & 0 deletions src/embed/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ pub use cache::{
pub use embedder::{FastEmbedder, ModelType};

use anyhow::Result;
use std::collections::HashMap;
use std::env;
use std::sync::{Arc, Mutex};

Expand Down Expand Up @@ -298,6 +299,50 @@ impl Default for EmbeddingService {
}
}

/// Lazily-created, per-model cache of [`EmbeddingService`]s.
///
/// Serve mode is multi-repo and different repos may be indexed with different
/// embedding models (an older MiniLM index alongside a rebuilt EmbeddingGemma
/// one), so a single shared service is wrong: the query must be embedded with
/// the same model the target index was built with. The pool loads each model at
/// most once per serve instance and reuses it across MCP sessions and REST
/// handlers. Each model gets its own mutex, so queries against different models
/// do not serialise on one global lock.
#[derive(Default)]
pub struct EmbeddingServicePool {
services: Mutex<HashMap<ModelType, Arc<Mutex<EmbeddingService>>>>,
cache_dir: Option<std::path::PathBuf>,
}

impl EmbeddingServicePool {
/// Create a pool. `cache_dir` overrides the ONNX model cache directory
/// (`None` = fastembed's configured cache, i.e. the global models dir).
pub fn new(cache_dir: Option<std::path::PathBuf>) -> Self {
Self {
services: Mutex::new(HashMap::new()),
cache_dir,
}
}

/// Return the service for `model`, loading its ONNX model on first use.
///
/// The returned `Arc` is locked independently per model, so a caller can
/// hold it across an `embed_query` without blocking other models.
pub fn get(&self, model: ModelType) -> Result<Arc<Mutex<EmbeddingService>>> {
let mut guard = self
.services
.lock()
.map_err(|e| anyhow::anyhow!("Embedding service pool mutex poisoned: {e}"))?;
if let Some(existing) = guard.get(&model) {
return Ok(existing.clone());
}
let service = EmbeddingService::with_cache_dir(model, self.cache_dir.as_deref())?;
let arc = Arc::new(Mutex::new(service));
guard.insert(model, arc.clone());
Ok(arc)
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand All @@ -308,6 +353,50 @@ mod tests {
assert_eq!(model.dimensions(), 384);
}

/// The index-metadata reader must invert `write_metadata_fields`, and must
/// report "no answer" (None) for unknown/missing names rather than silently
/// claiming the default — callers decide the fallback.
#[test]
fn test_model_type_round_trips_through_index_metadata() {
for model in [
ModelType::AllMiniLML6V2Q,
ModelType::EmbeddingGemma300MQ4,
ModelType::BGEBaseENV15,
] {
let dir = tempfile::tempdir().unwrap();
let mut obj = serde_json::Map::new();
model.write_metadata_fields(&mut obj);
std::fs::write(
dir.path().join("metadata.json"),
serde_json::to_string(&obj).unwrap(),
)
.unwrap();
assert_eq!(
ModelType::from_index_metadata(dir.path()),
Some(model),
"reader must invert the writer for '{:?}'",
model
);
}

let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("metadata.json"),
r#"{"model_short_name":"not-a-real-model"}"#,
)
.unwrap();
assert_eq!(ModelType::from_index_metadata(dir.path()), None);

let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("metadata.json"), "{}").unwrap();
assert_eq!(ModelType::from_index_metadata(dir.path()), None);

assert_eq!(
ModelType::from_index_metadata(std::path::Path::new("/nonexistent-db-dir")),
None
);
}

#[test]
#[ignore] // Requires model download
fn test_embedding_service_creation() {
Expand Down
2 changes: 1 addition & 1 deletion src/index/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -504,7 +504,7 @@ impl IndexManager {
///
/// Fails fast if metadata is missing, names an unknown model, or records a
/// dimension count that disagrees with the resolved model.
fn resolve_embed_model(db_path: &Path) -> Result<(ModelType, usize)> {
pub(crate) fn resolve_embed_model(db_path: &Path) -> Result<(ModelType, usize)> {
let metadata_path = db_path.join("metadata.json");
if !metadata_path.exists() {
return Err(anyhow::anyhow!(
Expand Down
Loading
Loading