From 96bab1bb4784a303164b68dc539094c14cc4b378 Mon Sep 17 00:00:00 2001 From: allengaoo Date: Wed, 18 Mar 2026 23:57:36 +0800 Subject: [PATCH 1/3] feat: implement EMBEDDING_DIM=0 auto-infer and add dim mismatch detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The documentation has long promised that EMBEDDING_DIM=0 would auto-infer the embedding dimension from the configured service, but this was never actually implemented โ€” the default silently fell back to 1024, and EMBEDDING_DIM=0 would have caused a vecf32(0) SQL error. This commit delivers the promised behaviour: **Auto-infer (EMBEDDING_DIM=0, new default)** - `probe_embedding_dim()` builds a temporary HttpEmbedder, calls `embed("dimension probe")`, and returns `vec.len()` as the actual dimension before any database schema is created or validated. - Both `cmd_serve` and `cmd_mcp` (embedded mode) call the probe when `cfg.embedding_dim == 0 && cfg.has_embedding()`. - `cfg.embedding_dim` is updated in-place so `build_embedder()` and subsequent code all see the correct value. - Clear error message when the probe fails, with explicit guidance to set EMBEDDING_DIM if the embedding service is unavailable at boot. **Dimension mismatch detection** - `SqlMemoryStore::check_embedding_dim_compat()` queries `information_schema.columns` for the actual `vecf32(N)` type of the `embedding` column and compares it against `self.embedding_dim`. - Called after `migrate()` in both `cmd_serve` and `cmd_mcp`. - Returns a descriptive error (rather than silently failing on the first INSERT) when the schema dimension differs from the config. **Config default change** - `Config::from_env()` now defaults `embedding_dim` to `0` instead of `1024`, making auto-infer the out-of-the-box experience. **Docs** - README and skills/deployment/SKILL.md updated to reflect that EMBEDDING_DIM=0 now actually auto-infers the dimension. Fixes: EMBEDDING_DIM=0 documented but not implemented. Closes: silent dimension-mismatch failures when switching embedding models. --- README.md | 5 +- memoria/crates/memoria-cli/src/main.rs | 66 ++++++++++++++++++++ memoria/crates/memoria-service/src/config.rs | 2 +- memoria/crates/memoria-storage/src/store.rs | 47 ++++++++++++++ skills/deployment/SKILL.md | 2 +- 5 files changed, 117 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 24695f2a..2c8bb408 100644 --- a/README.md +++ b/README.md @@ -166,7 +166,7 @@ This creates: The generated `mcp.json` includes all environment variables (empty = not configured). Edit the file to fill in your values. -โš ๏ธ **Configure embedding BEFORE the MCP server starts for the first time.** Tables are created on first startup with the configured dimension. +๐Ÿ’ก **Embedding dimension** defaults to `EMBEDDING_DIM=0` (auto-infer): Memoria probes your embedding service on first startup and uses the returned vector length. Set `EMBEDDING_DIM` explicitly if the service may be unavailable at boot time. Either way, the dimension is locked into the database schema on first run โ€” changing it later requires dropping the schema. ### 4. Restart & verify @@ -488,8 +488,7 @@ Leave all empty to use local embedding (all-MiniLM-L6-v2, dim=384). **๐Ÿ’ก Local Embedding Tips:** Local embedding requires building from source with `--features local-embedding` (pre-built binaries don't include it). See [Local Embedding Guide](skills/local-embedding/SKILL.md) for build instructions, supported models, and troubleshooting. -**โš ๏ธ CRITICAL: Configure embedding BEFORE the MCP server starts for the first time.** - Tables are created on first startup with the configured dimension. Changing it later requires re-creating the embedding column (destructive). +**๐Ÿ’ก Embedding dimension is auto-inferred by default** (`EMBEDDING_DIM=0`): Memoria probes your embedding service on startup to detect the correct dimension. The dimension is then locked into the database schema โ€” changing models later requires dropping the schema (destructive). Set `EMBEDDING_DIM` explicitly if you need deterministic startup without a probe call. --- diff --git a/memoria/crates/memoria-cli/src/main.rs b/memoria/crates/memoria-cli/src/main.rs index 789a31c6..7716c01c 100644 --- a/memoria/crates/memoria-cli/src/main.rs +++ b/memoria/crates/memoria-cli/src/main.rs @@ -327,9 +327,17 @@ async fn cmd_serve(db_url: Option, port: u16, master_key: String) -> Res cfg.db_url = v; } + // Auto-infer embedding dimension when EMBEDDING_DIM=0 (or unset). + // Probes the embedding service with a test call and uses the returned + // vector length as the actual dimension. + if cfg.embedding_dim == 0 && cfg.has_embedding() { + cfg.embedding_dim = probe_embedding_dim(&cfg).await?; + } + tracing::info!( db_url = %cfg.db_url, port = port, instance_id = %cfg.instance_id, + embedding_dim = cfg.embedding_dim, has_llm = cfg.has_llm(), has_embedding = cfg.has_embedding(), governance_plugin_binding = %cfg.governance_plugin_binding, "Starting Memoria API server" @@ -337,6 +345,7 @@ async fn cmd_serve(db_url: Option, port: u16, master_key: String) -> Res let store = SqlMemoryStore::connect(&cfg.db_url, cfg.embedding_dim).await?; store.migrate().await?; + store.check_embedding_dim_compat().await?; let pool = MySqlPool::connect(&cfg.db_url).await?; let git = Arc::new(GitForDataService::new(pool, &cfg.db_name)); @@ -432,9 +441,15 @@ async fn cmd_mcp( cfg.db_name = v; } + // Auto-infer embedding dimension when EMBEDDING_DIM=0 (or unset). + if cfg.embedding_dim == 0 && cfg.has_embedding() { + cfg.embedding_dim = probe_embedding_dim(&cfg).await?; + } + tracing::info!( db_url = %cfg.db_url, embedding_provider = %cfg.embedding_provider, + embedding_dim = cfg.embedding_dim, has_llm = cfg.has_llm(), governance_plugin_binding = %cfg.governance_plugin_binding, user = %cfg.user, @@ -443,6 +458,7 @@ async fn cmd_mcp( let store = SqlMemoryStore::connect(&cfg.db_url, cfg.embedding_dim).await?; store.migrate().await?; + store.check_embedding_dim_compat().await?; let pool = MySqlPool::connect(&cfg.db_url).await?; let git = Arc::new(GitForDataService::new(pool, &cfg.db_name)); @@ -764,6 +780,56 @@ fn cmd_plugin_dev_keygen(dir: &Path) -> Result<()> { // โ”€โ”€ Shared helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +/// Probe the configured embedding service to determine the vector dimension. +/// +/// Called when `EMBEDDING_DIM=0` (the default). Makes a single embedding +/// request with a short probe string and returns `vec.len()` as the +/// actual dimension, which is then used to create or validate the database +/// schema. +/// +/// # Errors +/// Returns an error if the embedding service is unreachable or returns an +/// empty vector, with a suggestion to set `EMBEDDING_DIM` explicitly. +async fn probe_embedding_dim(cfg: &memoria_service::Config) -> Result { + use memoria_core::interfaces::EmbeddingProvider; + use memoria_embedding::HttpEmbedder; + + // Build a temporary embedder with dim=0 (dim is not used by embed()). + let embedder = HttpEmbedder::new( + &cfg.embedding_base_url, + &cfg.embedding_api_key, + &cfg.embedding_model, + 0, + ); + + tracing::info!( + model = %cfg.embedding_model, + base_url = %cfg.embedding_base_url, + "EMBEDDING_DIM=0: probing embedding service to auto-infer dimension" + ); + + let vec = embedder + .embed("dimension probe") + .await + .map_err(|e| anyhow::anyhow!( + "EMBEDDING_DIM=0 but the embedding probe failed: {e}. \ + Set EMBEDDING_DIM explicitly (e.g. EMBEDDING_DIM=768 for \ + nomic-embed-text, EMBEDDING_DIM=1024 for BAAI/bge-m3) or \ + check that your embedding service is reachable." + ))?; + + if vec.is_empty() { + return Err(anyhow::anyhow!( + "EMBEDDING_DIM=0: embedding service returned an empty vector. \ + Set EMBEDDING_DIM explicitly." + )); + } + + let dim = vec.len(); + tracing::info!(embedding_dim = dim, "Auto-inferred embedding dimension"); + Ok(dim) +} + fn build_embedder( cfg: &memoria_service::Config, ) -> Option> { diff --git a/memoria/crates/memoria-service/src/config.rs b/memoria/crates/memoria-service/src/config.rs index 2f3afd0d..444cc926 100644 --- a/memoria/crates/memoria-service/src/config.rs +++ b/memoria/crates/memoria-service/src/config.rs @@ -64,7 +64,7 @@ impl Config { let embedding_dim = std::env::var("EMBEDDING_DIM") .ok() .and_then(|s| s.parse().ok()) - .unwrap_or(1024usize); + .unwrap_or(0usize); // 0 = auto-infer from embedding service at startup let llm_api_key = std::env::var("LLM_API_KEY").ok().filter(|s| !s.is_empty()); diff --git a/memoria/crates/memoria-storage/src/store.rs b/memoria/crates/memoria-storage/src/store.rs index 2e7831a7..71689355 100644 --- a/memoria/crates/memoria-storage/src/store.rs +++ b/memoria/crates/memoria-storage/src/store.rs @@ -462,6 +462,53 @@ impl SqlMemoryStore { Ok(()) } + /// Check that the configured embedding dimension matches the dimension + /// already stored in the database schema. + /// + /// If `mem_memories` already exists with a different dimension, returning + /// an error here is far better than silently failing on the first INSERT. + /// Called after `migrate()` so the table is guaranteed to exist. + /// + /// # Errors + /// Returns [`MemoriaError::Internal`] when a mismatch is detected, + /// with a human-readable message explaining how to resolve it. + pub async fn check_embedding_dim_compat(&self) -> Result<(), MemoriaError> { + // Query the actual column type stored in the schema, e.g. "vecf32(768)" + let col_type: Option = sqlx::query_scalar( + "SELECT column_type \ + FROM information_schema.columns \ + WHERE table_schema = DATABASE() \ + AND table_name = 'mem_memories' \ + AND column_name = 'embedding'", + ) + .fetch_optional(&self.pool) + .await + .map_err(db_err)?; + + if let Some(ct) = col_type { + // Parse "vecf32(768)" โ†’ Some(768) + if let Some(schema_dim) = ct + .trim_start_matches("vecf32(") + .trim_end_matches(')') + .parse::() + .ok() + { + if schema_dim != self.embedding_dim { + return Err(MemoriaError::Internal(format!( + "Embedding dimension mismatch: the database schema has \ + {}d vectors but Memoria is configured for {}d. \ + To fix: either set EMBEDDING_DIM={} to match the \ + existing schema, or drop the database (data loss) and \ + restart to rebuild with the new dimension.", + schema_dim, self.embedding_dim, schema_dim + ))); + } + } + } + + Ok(()) + } + // โ”€โ”€ Audit log โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ /// Create a safety snapshot before destructive operations. Best-effort. diff --git a/skills/deployment/SKILL.md b/skills/deployment/SKILL.md index 0e557144..1d40cc58 100644 --- a/skills/deployment/SKILL.md +++ b/skills/deployment/SKILL.md @@ -39,7 +39,7 @@ Services: API on `:8100`, MatrixOne on `:6001`. Verify: `curl http://localhost:8 | `MEMORIA_EMBEDDING_MODEL` | `all-MiniLM-L6-v2` | Model name | | `MEMORIA_EMBEDDING_API_KEY` | โ€” | Required if provider is `openai` | | `MEMORIA_EMBEDDING_BASE_URL` | โ€” | Custom endpoint (OpenAI-compatible) | -| `MEMORIA_EMBEDDING_DIM` | `0` (auto) | Embedding dimension | +| `MEMORIA_EMBEDDING_DIM` | `0` (auto) | Embedding dimension. `0` = auto-infer: Memoria probes the embedding service on startup and uses the returned vector length. Set explicitly (e.g. `768`, `1024`) to skip the probe or when the embedding service may be unavailable at boot time. | ### Distributed From cf881de7bf2236f9b9bd01d4b3ed1b1b1db4c99f Mon Sep 17 00:00:00 2001 From: allengaoo Date: Thu, 19 Mar 2026 19:00:23 +0800 Subject: [PATCH 2/3] fix: use if-let-Ok instead of if-let-Some(.ok()) to satisfy clippy Clippy lint `match_result_ok` requires replacing: if let Some(x) = expr.parse().ok() with the idiomatic: if let Ok(x) = expr.parse() This removes the intermediate Option conversion and makes the intent clearer. --- memoria/crates/memoria-storage/src/store.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/memoria/crates/memoria-storage/src/store.rs b/memoria/crates/memoria-storage/src/store.rs index 71689355..fa0d9867 100644 --- a/memoria/crates/memoria-storage/src/store.rs +++ b/memoria/crates/memoria-storage/src/store.rs @@ -486,12 +486,11 @@ impl SqlMemoryStore { .map_err(db_err)?; if let Some(ct) = col_type { - // Parse "vecf32(768)" โ†’ Some(768) - if let Some(schema_dim) = ct + // Parse "vecf32(768)" โ†’ 768 + if let Ok(schema_dim) = ct .trim_start_matches("vecf32(") .trim_end_matches(')') .parse::() - .ok() { if schema_dim != self.embedding_dim { return Err(MemoriaError::Internal(format!( From c4c287950f04baf2ef5eff76a83d0582500203bf Mon Sep 17 00:00:00 2001 From: allengaoo Date: Thu, 19 Mar 2026 19:19:09 +0800 Subject: [PATCH 3/3] ci: add fork pre-PR check workflow for feat/* branches Runs cargo check + clippy on every push to feat/**, fix/**, chore/** so issues are caught before opening a PR to upstream. --- .github/workflows/fork-pre-pr.yml | 35 +++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 .github/workflows/fork-pre-pr.yml diff --git a/.github/workflows/fork-pre-pr.yml b/.github/workflows/fork-pre-pr.yml new file mode 100644 index 00000000..8458637e --- /dev/null +++ b/.github/workflows/fork-pre-pr.yml @@ -0,0 +1,35 @@ +name: Fork Pre-PR Check + +# ๅœจ fork ไป“ๅบ“ๆŽจ้€ feat/* ๅˆ†ๆ”ฏๆ—ถ่‡ชๅŠจ่ฟ่กŒ๏ผŒ +# ๆ PR ๅˆฐไธŠๆธธๅ‰ๆๅ‰ๅ‘็Žฐ clippy / ็ผ–่ฏ‘้—ฎ้ข˜ใ€‚ +on: + push: + branches: + - "feat/**" + - "fix/**" + - "chore/**" + +env: + CARGO_TERM_COLOR: always + SQLX_OFFLINE: "true" + # ๆ— ้œ€็œŸๅฎž DB / Embedding key๏ผŒไป…ๅš้™ๆ€ๆฃ€ๆŸฅ + DATABASE_URL: mysql://root:111@localhost:6001/memoria_test + +jobs: + check-and-clippy: + name: Check & Clippy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@1.85.0 + with: + components: clippy + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: memoria + shared-key: fork-pre-pr + + - name: Check & Clippy + run: cd memoria && cargo check && cargo clippy -- -D warnings