Pārlūkot izejas kodu

feat(embeddings): cache embeddings on the search/insert path + cache settings + /stats telemetry

- B.2: add embeddingCacheSize/TtlSec/MaxBytes/Normalize to Settings (fromJson, toJson, PUT handler)
- B.2: setSnapshot() calls embeddingCache().reconfigure() so every bootstrap/save/hot-reload applies the cache config
- B.3: resolveVector() checks embeddingCache before calling EmbeddingClient; populates on miss; covers both /search (query_text) and /vectors insert (text)
- B.4: GET /api/v1/stats now includes an "embedding" object with cache_size, cache_capacity, cache_hits, cache_misses, cache_hit_ratio, cache_bytes
- B.5: integration test EmbeddingCacheDeduplicatesIdenticalQueryText verifies two identical query_text searches hit the mock server exactly once
- docs: openapi.json and llms.txt updated with the four new settings fields and the embedding stats object
Fszontagh 1 mēnesi atpakaļ
vecāks
revīzija
20dd6f1474

+ 2 - 2
api/llms.txt

@@ -65,12 +65,12 @@ Requires a `kind=vector` collection. Vectors are stored with cosine-similarity i
 All settings endpoints require an admin key.
 
 - `GET  /api/v1/settings` — Return current settings as JSON. `openai_api_key` is masked: the field is absent and `openai_api_key_set` (bool) indicates whether a key is configured.
-- `PUT  /api/v1/settings` `{openai_api_base?, openai_api_key?, default_embedding_model?, cors_origins?, session_ttl_minutes?, webui_enabled?, default_project?, embedding_connect_timeout_sec?, embedding_read_timeout_sec?}` — Merge the supplied fields into the current settings and persist. Omit `openai_api_key` (or pass an empty string) to keep the existing secret. Changes are hot-reloaded immediately.
+- `PUT  /api/v1/settings` `{openai_api_base?, openai_api_key?, default_embedding_model?, cors_origins?, session_ttl_minutes?, webui_enabled?, default_project?, embedding_connect_timeout_sec?, embedding_read_timeout_sec?, embedding_cache_size?, embedding_cache_ttl_sec?, embedding_cache_max_bytes?, embedding_cache_normalize?}` — Merge the supplied fields into the current settings and persist. Omit `openai_api_key` (or pass an empty string) to keep the existing secret. Changes are hot-reloaded immediately; cache settings are applied instantly via `reconfigure()`.
 
 ## Stats
 
 - `GET /api/v1/projects/{project}/stats` — Per-project stats (collections, document counts). Accessible to any key granted the project.
-- `GET /api/v1/stats` — Server-wide stats including `memory_pressure_level`. Admin only.
+- `GET /api/v1/stats` — Server-wide stats including `memory_pressure_level` and an `embedding` object with cache telemetry (`cache_size`, `cache_capacity`, `cache_hits`, `cache_misses`, `cache_hit_ratio`, `cache_bytes`). Admin only.
 
 ## Ops
 

+ 7 - 2
api/openapi.json

@@ -848,7 +848,11 @@
                   "webui_enabled": { "type": "boolean" },
                   "default_project": { "type": "string" },
                   "embedding_connect_timeout_sec": { "type": "integer", "minimum": 1, "description": "Embedding HTTP connect timeout in seconds (default 10)" },
-                  "embedding_read_timeout_sec": { "type": "integer", "minimum": 1, "description": "Embedding HTTP read timeout in seconds (default 60)" }
+                  "embedding_read_timeout_sec": { "type": "integer", "minimum": 1, "description": "Embedding HTTP read timeout in seconds (default 60)" },
+                  "embedding_cache_size": { "type": "integer", "minimum": 0, "description": "Maximum number of cached embedding entries (default 4096)" },
+                  "embedding_cache_ttl_sec": { "type": "integer", "minimum": 0, "description": "Cache entry TTL in seconds; 0 = no expiry (default 86400)" },
+                  "embedding_cache_max_bytes": { "type": "integer", "minimum": 0, "description": "Maximum total bytes of cached vectors (default 268435456 = 256 MB)" },
+                  "embedding_cache_normalize": { "type": "boolean", "description": "When true, text is trimmed and lower-cased before cache lookup (default false)" }
                 }
               }
             }
@@ -876,7 +880,8 @@
                     "projects": { "type": "integer" },
                     "memory_pressure_level": { "type": "integer" },
                     "collections": { "type": "integer" },
-                    "documents": { "type": "integer" }
+                    "documents": { "type": "integer" },
+                    "embedding": { "type": "object", "properties": { "cache_size": { "type": "integer" }, "cache_capacity": { "type": "integer" }, "cache_hits": { "type": "integer" }, "cache_misses": { "type": "integer" }, "cache_hit_ratio": { "type": "number" }, "cache_bytes": { "type": "integer" } } }
                   }
                 }
               }

+ 8 - 0
src/handlers/settings.cpp

@@ -41,6 +41,14 @@ void registerSettingsRoutes(ApiServer& s) {
             updated.embeddingConnectTimeoutSec = body["embedding_connect_timeout_sec"].get<uint32_t>();
         if (body.contains("embedding_read_timeout_sec"))
             updated.embeddingReadTimeoutSec = body["embedding_read_timeout_sec"].get<uint32_t>();
+        if (body.contains("embedding_cache_size"))
+            updated.embeddingCacheSize = body["embedding_cache_size"].get<uint32_t>();
+        if (body.contains("embedding_cache_ttl_sec"))
+            updated.embeddingCacheTtlSec = body["embedding_cache_ttl_sec"].get<uint32_t>();
+        if (body.contains("embedding_cache_max_bytes"))
+            updated.embeddingCacheMaxBytes = body["embedding_cache_max_bytes"].get<uint64_t>();
+        if (body.contains("embedding_cache_normalize"))
+            updated.embeddingCacheNormalize = body["embedding_cache_normalize"].get<bool>();
         d->settings.save(updated);
         sendJson(res, 200, {{"ok", true}});
     });

+ 11 - 0
src/handlers/stats.cpp

@@ -1,4 +1,5 @@
 #include "collection_registry.hpp"
+#include "embedding_cache.hpp"
 #include "errors.hpp"
 #include "json_http.hpp"
 #include "server.hpp"
@@ -32,6 +33,16 @@ void registerStatsRoutes(ApiServer& s) {
         out["memory_pressure_level"]   = mem.pressureLevel;
         out["memory_pressure_percent"] = mem.pressurePercent;
         out["projects"]                = d->db.listProjects().size();
+        auto cs = embeddingCache().stats();
+        double total = double(cs.hits + cs.misses);
+        out["embedding"] = {
+            {"cache_size",      cs.size},
+            {"cache_capacity",  cs.capacity},
+            {"cache_hits",      cs.hits},
+            {"cache_misses",    cs.misses},
+            {"cache_hit_ratio", total > 0 ? cs.hits / total : 0.0},
+            {"cache_bytes",     cs.bytes}
+        };
         sendJson(res, 200, out);
     });
 }

+ 13 - 3
src/handlers/vectors.cpp

@@ -1,4 +1,5 @@
 #include "collection_registry.hpp"
+#include "embedding_cache.hpp"
 #include "embeddings.hpp"
 #include "errors.hpp"
 #include "json_http.hpp"
@@ -23,9 +24,18 @@ std::vector<float> resolveVector(ServerDeps* d, const CollectionMeta& meta, cons
     } else if (body.contains(textField) && body[textField].is_string()) {
         auto snap = d->settings.snapshot();
         std::string model = meta.embeddingModel.empty() ? snap->defaultEmbeddingModel : meta.embeddingModel;
-        EmbeddingClient emb(snap->openaiApiBase, snap->openaiApiKey,
-                            snap->embeddingConnectTimeoutSec, snap->embeddingReadTimeoutSec);
-        v = emb.embed(model, body[textField].get<std::string>());
+        // This helper is shared by /search (query_text) AND /vectors insert (text), so the cache
+        // intentionally covers both paths — re-embedding identical text is wasted work either way.
+        const std::string text = body[textField].get<std::string>();
+        EmbeddingCache::Key key{snap->openaiApiBase, model, text};
+        if (auto hit = embeddingCache().get(key)) {
+            v = std::move(*hit);
+        } else {
+            EmbeddingClient emb(snap->openaiApiBase, snap->openaiApiKey,
+                                snap->embeddingConnectTimeoutSec, snap->embeddingReadTimeoutSec);
+            v = emb.embed(model, text);
+            embeddingCache().put(key, v);  // copy, not move: v is still needed for the dimension check below
+        }
     } else {
         throw ApiError(ErrCode::Unprocessable, "validation",
                        std::string("provide '") + vecField + "' or '" + textField + "'");

+ 9 - 1
src/settings.cpp

@@ -14,6 +14,10 @@ Settings Settings::fromJson(const nlohmann::json& j) {
     s.defaultProject             = j.value("default_project", s.defaultProject);
     s.embeddingConnectTimeoutSec = j.value("embedding_connect_timeout_sec", s.embeddingConnectTimeoutSec);
     s.embeddingReadTimeoutSec    = j.value("embedding_read_timeout_sec", s.embeddingReadTimeoutSec);
+    s.embeddingCacheSize         = j.value("embedding_cache_size",        s.embeddingCacheSize);
+    s.embeddingCacheTtlSec       = j.value("embedding_cache_ttl_sec",     s.embeddingCacheTtlSec);
+    s.embeddingCacheMaxBytes     = j.value("embedding_cache_max_bytes",   s.embeddingCacheMaxBytes);
+    s.embeddingCacheNormalize    = j.value("embedding_cache_normalize",   s.embeddingCacheNormalize);
     return s;
 }
 
@@ -23,7 +27,11 @@ nlohmann::json Settings::toJson() const {
             {"session_ttl_minutes", sessionTtlMinutes}, {"webui_enabled", webuiEnabled},
             {"default_project", defaultProject},
             {"embedding_connect_timeout_sec", embeddingConnectTimeoutSec},
-            {"embedding_read_timeout_sec", embeddingReadTimeoutSec}};
+            {"embedding_read_timeout_sec", embeddingReadTimeoutSec},
+            {"embedding_cache_size",        embeddingCacheSize},
+            {"embedding_cache_ttl_sec",     embeddingCacheTtlSec},
+            {"embedding_cache_max_bytes",   embeddingCacheMaxBytes},
+            {"embedding_cache_normalize",   embeddingCacheNormalize}};
 }
 
 } // namespace svapi

+ 4 - 0
src/settings.hpp

@@ -16,6 +16,10 @@ struct Settings {
     std::string defaultProject           = "default";
     uint32_t    embeddingConnectTimeoutSec = 10;
     uint32_t    embeddingReadTimeoutSec    = 60;
+    uint32_t    embeddingCacheSize         = 4096;       // max entries
+    uint32_t    embeddingCacheTtlSec       = 86400;      // 0 = no expiry
+    uint64_t    embeddingCacheMaxBytes     = 268435456;  // 256 MB
+    bool        embeddingCacheNormalize    = false;      // opt-in trim+lowercase
 
     static Settings fromJson(const nlohmann::json& j);
     nlohmann::json  toJson() const;

+ 3 - 0
src/settings_store.cpp

@@ -1,4 +1,5 @@
 #include "settings_store.hpp"
+#include "embedding_cache.hpp"
 #include <spdlog/spdlog.h>
 
 namespace svapi {
@@ -23,6 +24,8 @@ void SettingsStore::bootstrap(const std::string& envOpenAiKey) {
 }
 
 void SettingsStore::setSnapshot(Settings s) {
+    embeddingCache().reconfigure(s.embeddingCacheSize, s.embeddingCacheMaxBytes,
+                                 s.embeddingCacheTtlSec, s.embeddingCacheNormalize);
     auto p = std::make_shared<const Settings>(std::move(s));
     std::lock_guard<std::mutex> lk(m_);
     snap_ = p;

+ 56 - 0
tests/test_api_integration.cpp

@@ -1,4 +1,5 @@
 #include "api_fixture.hpp"
+#include "embedding_cache.hpp"
 using svapi::testutil::ApiFixture;
 
 TEST_F(ApiFixture, HealthzPublic) { auto r = noAuth().Get("/healthz"); ASSERT_TRUE(r); EXPECT_EQ(r->status, 200); }
@@ -396,3 +397,58 @@ TEST_F(ApiFixture, DeleteCollectionPurgesVectorIndex) {
 
     c.Delete((base + "/leak_test").c_str());
 }
+
+// B.5: identical query_text searches should hit the embedding cache on the second call
+// (the upstream embedding endpoint is called exactly once for the same text).
+TEST_F(ApiFixture, EmbeddingCacheDeduplicatesIdenticalQueryText) {
+    // Clear the process-singleton cache to avoid cross-test pollution.
+    svapi::embeddingCache().clear();
+
+    // Spin up a counting mock embedding server.
+    std::atomic<int> embReqCount{0};
+    httplib::Server countingMock;
+    countingMock.Post("/v1/embeddings", [&](const httplib::Request&, httplib::Response& res) {
+        ++embReqCount;
+        nlohmann::json emb = nlohmann::json::array();
+        for (int i = 0; i < mockDim_; ++i) emb.push_back(0.1f * (i + 1));
+        res.set_content(nlohmann::json{{"data", {{{"embedding", emb}}}}}.dump(), "application/json");
+    });
+    int countingPort = countingMock.bind_to_any_port("127.0.0.1");
+    std::thread countingThread([&]{ countingMock.listen_after_bind(); });
+
+    // Point settings at the counting mock.
+    svapi::Settings s = *settings_->snapshot();
+    s.openaiApiBase = "http://127.0.0.1:" + std::to_string(countingPort);
+    s.openaiApiKey  = "test";
+    settings_->save(s);
+
+    // Create a vector collection whose dimension matches the mock's returned vector length.
+    auto c = admin();
+    const std::string base = "/api/v1/projects/" + project_ + "/collections";
+    ASSERT_EQ(c.Post(base.c_str(),
+        nlohmann::json{{"name","cache_test"},{"kind","vector"},{"vector_dimension",mockDim_}}.dump(),
+        "application/json")->status, 201);
+
+    // POST /search twice with the same query_text.
+    const std::string searchPath = base + "/cache_test/search";
+    const nlohmann::json searchBody = {{"query_text", "cache dedup test"}, {"top_k", 5}};
+
+    auto r1 = c.Post(searchPath.c_str(), searchBody.dump(), "application/json");
+    ASSERT_TRUE(r1); EXPECT_EQ(r1->status, 200);
+
+    auto r2 = c.Post(searchPath.c_str(), searchBody.dump(), "application/json");
+    ASSERT_TRUE(r2); EXPECT_EQ(r2->status, 200);
+
+    // The upstream mock must have been called exactly once (second search hit the cache).
+    EXPECT_EQ(embReqCount.load(), 1)
+        << "expected exactly 1 upstream embedding request for 2 identical query_text searches";
+
+    // Cleanup.
+    c.Delete((base + "/cache_test").c_str());
+    // Restore settings to the fixture's original mock so other tests are unaffected.
+    svapi::Settings s2 = *settings_->snapshot();
+    s2.openaiApiBase = "http://127.0.0.1:" + std::to_string(mockPort_);
+    settings_->save(s2);
+    countingMock.stop();
+    countingThread.join();
+}