diff --git a/cmd/memory/embed.go b/cmd/memory/embed.go index 5ce6c952..337fdbe5 100644 --- a/cmd/memory/embed.go +++ b/cmd/memory/embed.go @@ -46,12 +46,19 @@ Modes: return fmt.Errorf("embedding stats: %w", err) } available := ec.Available() + embeddingAvailable := false + if available { + // Discovery can succeed even when the configured model is missing + // or cannot embed. Keep this extra probe out of normal recall. + _, err := ec.Embed("availability probe") + embeddingAvailable = err == nil + } output := map[string]interface{}{ "total_insights": total, "embedded": embedded, "coverage": fmt.Sprintf("%.0f%%", float64(embedded)/float64(max(total, 1))*100), - "embedding_available": available, - "ollama_available": available, // Backward-compatible alias. + "embedding_available": embeddingAvailable, + "ollama_available": available, // Legacy provider-reachability field. "protocol": ec.Protocol(), "model": ec.Model(), } diff --git a/cmd/memory/embed_test.go b/cmd/memory/embed_test.go new file mode 100644 index 00000000..85492dd3 --- /dev/null +++ b/cmd/memory/embed_test.go @@ -0,0 +1,101 @@ +package memory + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + + "github.com/mnemon-dev/mnemon/internal/memory/embed" + "github.com/mnemon-dev/mnemon/internal/memory/store" +) + +func TestEmbedStatusChecksConfiguredModel(t *testing.T) { + for _, tc := range []struct { + name, protocol, body string + discoveryStatus, embedStatus int + wantProvider, wantEmbedding bool + wantProbes int32 + }{ + {"ollama ready", "ollama", `{"embeddings":[[1,0]]}`, 200, 200, true, true, 1}, + {"ollama missing model", "ollama", `{"error":"model is missing"}`, 200, 404, true, false, 1}, + {"ollama empty vector", "ollama", `{"embeddings":[[]]}`, 200, 200, true, false, 1}, + {"ollama unavailable", "ollama", "", 503, 503, false, false, 0}, + {"openai ready", "openai", `{"data":[{"embedding":[1,0]}]}`, 200, 200, true, true, 1}, + {"openai missing model", "openai", `{"error":"model is missing"}`, 200, 404, true, false, 1}, + {"openai malformed vector", "openai", `{"data":[{"embedding":"invalid"}]}`, 200, 200, true, false, 1}, + {"openai without discovery", "openai", `{"data":[{"embedding":[1,0]}]}`, 404, 200, true, true, 2}, + } { + t.Run(tc.name, func(t *testing.T) { + oldDir, oldStore, oldReadOnly := dataDir, storeName, readOnly + oldStatus, oldModel := embedStatus, embedModel + t.Cleanup(func() { + dataDir, storeName, readOnly = oldDir, oldStore, oldReadOnly + embedStatus, embedModel = oldStatus, oldModel + }) + dataDir, storeName, readOnly = t.TempDir(), "embedding-status", false + embedStatus, embedModel = true, "selected-model" + t.Setenv("MNEMON_EMBED_PROTOCOL", tc.protocol) + t.Setenv("MNEMON_EMBED_DIMENSIONS", "") + t.Setenv("MNEMON_EMBED_API_KEY", "synthetic-key") + t.Setenv("MNEMON_EMBED_MODEL", "ignored-env-model") + var probes atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + w.WriteHeader(tc.discoveryStatus) + _, _ = w.Write([]byte(`{"models":[{"name":"different-model:latest"}]}`)) + return + } + probes.Add(1) + var request struct{ Model, Input string } + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + t.Errorf("decode embedding probe: %v", err) + } + if request.Model != "selected-model" || request.Input != "availability probe" { + t.Errorf("probe must use configured model and synthetic input: %+v", request) + } + w.WriteHeader(tc.embedStatus) + _, _ = w.Write([]byte(tc.body)) + })) + defer srv.Close() + t.Setenv("MNEMON_EMBED_ENDPOINT", srv.URL) + + db, err := store.Open(store.StoreDir(dataDir, storeName)) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + insertTestInsight(t, db, "status-seed", "stored memory must not be sent by status", "test", "2026-01-01T00:00:00Z") + if err := db.UpdateEmbedding("status-seed", embed.SerializeVector([]float64{1, 0})); err != nil { + t.Fatal(err) + } + if err := db.Close(); err != nil { + t.Fatal(err) + } + var runErr error + out := captureStdout(t, func() { runErr = embedCmd.RunE(embedCmd, nil) }) + if runErr != nil { + t.Fatal(runErr) + } + var result struct { + EmbeddingAvailable bool `json:"embedding_available"` + OllamaAvailable bool `json:"ollama_available"` + Model string `json:"model"` + Coverage string `json:"coverage"` + } + if err := json.Unmarshal([]byte(out), &result); err != nil { + t.Fatalf("decode status: %v\n%s", err, out) + } + if result.EmbeddingAvailable != tc.wantEmbedding || result.OllamaAvailable != tc.wantProvider || result.Model != "selected-model" { + t.Errorf("unexpected embedding status: %s", out) + } + if result.Coverage != "100%" { + t.Errorf("model availability must not alter stored embedding coverage: %s", out) + } + if got := probes.Load(); got != tc.wantProbes { + t.Errorf("embedding probes = %d, want %d", got, tc.wantProbes) + } + }) + } +} diff --git a/cmd/memory/recall.go b/cmd/memory/recall.go index 7b0b88ab..b19f6a76 100644 --- a/cmd/memory/recall.go +++ b/cmd/memory/recall.go @@ -184,7 +184,10 @@ meta.intent and meta.intent_source (auto or override). --basic bypasses intent.` var queryVec []float64 ec := embed.NewClientWithModel(resolveEmbedModel()) if ec.Available() { - queryVec, _ = ec.Embed(keyword) + queryVec, err = ec.Embed(keyword) + if err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "warning: query embedding failed for model %q; continuing without vector similarity (check --embed-model or MNEMON_EMBED_MODEL and run 'mnemon embed --status')\n", ec.Model()) + } } // Extract query entities at cmd layer (avoid graph->search circular dep). diff --git a/cmd/memory/recall_embedding_test.go b/cmd/memory/recall_embedding_test.go new file mode 100644 index 00000000..8b72b43c --- /dev/null +++ b/cmd/memory/recall_embedding_test.go @@ -0,0 +1,111 @@ +package memory + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + "github.com/mnemon-dev/mnemon/internal/memory/embed" + "github.com/mnemon-dev/mnemon/internal/memory/search" + "github.com/mnemon-dev/mnemon/internal/memory/store" +) + +func TestRecallReportsEmbeddingFailure(t *testing.T) { + for _, tc := range []struct { + name, body string + status int + wantWarning bool + }{ + {"missing model", `{"error":"synthetic-secret and private-provider-echo"}`, 404, true}, + {"invalid vector", `{"embeddings":[[]]}`, 200, true}, + {"healthy model", `{"embeddings":[[1,0]]}`, 200, false}, + } { + t.Run(tc.name, func(t *testing.T) { + oldDir, oldStore, oldReadOnly, oldModel := dataDir, storeName, readOnly, embedModel + oldBasic, oldBrief, oldVerbose, oldLimit := recBasic, recBrief, recVerbose, recLimit + oldIntent, oldExcerpt := recIntent, recExcerpt + oldErr := recallCmd.ErrOrStderr() + t.Cleanup(func() { + dataDir, storeName, readOnly, embedModel = oldDir, oldStore, oldReadOnly, oldModel + recBasic, recBrief, recVerbose, recLimit = oldBasic, oldBrief, oldVerbose, oldLimit + recIntent, recExcerpt = oldIntent, oldExcerpt + recallCmd.SetErr(oldErr) + }) + dataDir, storeName, readOnly, embedModel = t.TempDir(), "embedding-recall", false, "selected-model" + recBasic, recLimit, recIntent, recExcerpt = false, 5, "GENERAL", 240 + t.Setenv("MNEMON_EMBED_PROTOCOL", "ollama") + t.Setenv("MNEMON_EMBED_DIMENSIONS", "") + t.Setenv("MNEMON_EMBED_API_KEY", "synthetic-secret") + var embeds atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/tags" { + _, _ = w.Write([]byte(`{"models":[]}`)) + return + } + embeds.Add(1) + w.WriteHeader(tc.status) + _, _ = w.Write([]byte(tc.body)) + })) + defer srv.Close() + t.Setenv("MNEMON_EMBED_ENDPOINT", srv.URL) + db, err := store.Open(store.StoreDir(dataDir, storeName)) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + insertTestInsight(t, db, "embedding-seed", "SQLite snapshot memory", "test", "2026-01-01T00:00:00Z") + if err := db.UpdateEmbedding("embedding-seed", embed.SerializeVector([]float64{1, 0})); err != nil { + t.Fatal(err) + } + if err := db.Close(); err != nil { + t.Fatal(err) + } + for _, mode := range []string{"compact", "brief", "verbose"} { + t.Run(mode, func(t *testing.T) { + recBrief, recVerbose = mode == "brief", mode == "verbose" + var stderr bytes.Buffer + recallCmd.SetErr(&stderr) + var runErr error + out := captureStdout(t, func() { runErr = recallCmd.RunE(recallCmd, []string{"SQLite"}) }) + if runErr != nil { + t.Fatalf("recall must fall back successfully: %v", runErr) + } + var response struct{ Results []json.RawMessage } + if err := json.Unmarshal([]byte(out), &response); err != nil || len(response.Results) != 1 || !strings.Contains(string(response.Results[0]), "embedding-seed") { + t.Fatalf("recall lost its JSON result: %v\n%s", err, out) + } + warning := stderr.String() + if tc.wantWarning { + if strings.Count(warning, "\n") != 1 || !strings.Contains(warning, "without vector similarity") || !strings.Contains(warning, "selected-model") || !strings.Contains(warning, "--embed-model") { + t.Errorf("missing actionable one-line fallback warning: %q", warning) + } + } else if warning != "" { + t.Errorf("healthy embedding should be quiet: %q", warning) + } + for _, secret := range []string{"synthetic-secret", "private-provider-echo"} { + if strings.Contains(warning+out, secret) { + t.Errorf("provider content leaked: %q", secret) + } + } + if recVerbose { + var verbose search.RecallResponse + if err := json.Unmarshal([]byte(out), &verbose); err != nil { + t.Fatal(err) + } + similarity := verbose.Results[0].Signals.Similarity + if (tc.wantWarning && similarity != 0) || (!tc.wantWarning && similarity < 0.99) { + t.Errorf("unexpected vector similarity: %v", similarity) + } + } + }) + } + if got := embeds.Load(); got != 3 { + t.Errorf("recall made %d embedding requests, want one per query", got) + } + }) + } +} diff --git a/docs/USAGE.md b/docs/USAGE.md index 03c168a5..60fa114d 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -437,6 +437,10 @@ Mnemon works fully without an embedding provider — all core features (remember When the configured provider is unavailable, the reranking system automatically redistributes similarity weight to keyword and graph signals — no configuration or degraded-mode flag is needed. Mnemon checks provider availability at runtime with a 2-second timeout. +If the provider responds but the query embedding fails (for example, because +the selected model is missing), `recall` prints one warning to stderr and +continues without vector similarity. JSON results remain on stdout. + ### Setup Ollama remains the default provider: @@ -465,6 +469,14 @@ Verify with: mnemon embed --status ``` +`embedding_available` verifies that the configured model returns a nonempty +vector, using a small synthetic input and the normal 30-second embedding +timeout. This explicit diagnostic can load the model and may incur a provider +request charge; it sends no stored memories. The legacy `ollama_available` +field keeps its provider-availability meaning for both protocols, so it can be +`true` while `embedding_available` is `false`. Coverage counts stored vectors +and does not establish that the current model can produce new ones. + ```json { "total_insights": 87, @@ -477,7 +489,7 @@ mnemon embed --status } ``` -`ollama_available` is retained as a compatibility alias for existing scripts; +`ollama_available` is retained for compatibility with existing scripts; new integrations should use `embedding_available` and `protocol`. ### Backfilling existing insights diff --git a/docs/zh/USAGE.md b/docs/zh/USAGE.md index fff9d05f..fe39be1f 100644 --- a/docs/zh/USAGE.md +++ b/docs/zh/USAGE.md @@ -400,6 +400,9 @@ Mnemon 无需嵌入服务即可完整运行 — 所有核心功能(remember、 配置的嵌入服务不可用时,重排序系统会自动将相似度权重重新分配给关键词和图信号 — 无需额外配置或降级模式标志。Mnemon 在运行时以 2 秒超时检测服务可用性。 +如果服务有响应,但查询嵌入生成失败(例如所选模型不存在),`recall` 会向 stderr +输出一行警告,并继续使用不含向量相似度的检索。JSON 结果仍输出到 stdout。 + ### 安装 Ollama 仍是默认服务: @@ -425,6 +428,12 @@ export MNEMON_EMBED_API_KEY=sk-... # 无需认证的本地服务器可省略 mnemon embed --status ``` +`embedding_available` 会使用一段简短的合成文本,验证当前配置的模型能否返回非空 +向量,采用正常嵌入请求的 30 秒超时。这个显式诊断可能加载模型,也可能产生服务商 +请求费用,但不会发送已保存的记忆。旧字段 `ollama_available` 对两种协议均保留 +服务可用性的含义,因此它可能为 `true`,而 `embedding_available` 为 `false`。 +覆盖率统计已存储的向量,不能证明当前模型仍能生成新向量。 + ```json { "total_insights": 87,