Kaynağa Gözat

feat(embeddings): honor Cache-Control: no-store to bypass the embedding cache

When a request carries Cache-Control: no-store the text-embedding path in
resolveVector() now skips both the cache get and the cache put, so the text
is re-embedded fresh and the result is never stored. The flag is derived per-
request by a new file-local wantsNoStore() helper and threaded into both the
/vectors (insert) and /search handlers. Docs updated in api/openapi.json and
api/llms.txt; new integration test EmbeddingCacheBypassedByNoCacheControlHeader
asserts exactly two upstream embedding calls for two identical no-store searches.
Fszontagh 1 ay önce
ebeveyn
işleme
2404e3ccf1
4 değiştirilmiş dosya ile 77 ekleme ve 6 silme
  1. 2 0
      api/llms.txt
  2. 2 1
      api/openapi.json
  3. 13 5
      src/handlers/vectors.cpp
  4. 60 0
      tests/test_api_integration.cpp

+ 2 - 0
api/llms.txt

@@ -60,6 +60,8 @@ Requires a `kind=vector` collection. Vectors are stored with cosine-similarity i
 
   **Latency tip:** `query_text` triggers a synchronous server-side embedding call to the configured provider, which usually dominates request time (seconds for large models). Cosine search itself is sub-millisecond. If your client issues many searches (e.g. an LLM/n8n agent doing RAG), embed the query once on your side and pass `query_vector` to skip server-side embedding entirely.
 
+  **Cache bypass:** Send `Cache-Control: no-store` on a `/vectors` or `/search` request to skip the server-side embedding cache — the text is re-embedded fresh and the result is not stored in the cache.
+
 ## Settings (admin)
 
 All settings endpoints require an admin key.

+ 2 - 1
api/openapi.json

@@ -629,6 +629,7 @@
       ],
       "post": {
         "summary": "Store a vector; supply either text (auto-embedded) or an explicit vector array",
+        "description": "Store a vector. Supply either `text` (auto-embedded) or a pre-computed `vector` array. When supplying `text`, the server-side embedding result is cached by default. Send `Cache-Control: no-store` to bypass the cache: the text will be re-embedded fresh and the result will not be stored.",
         "operationId": "upsertVector",
         "requestBody": {
           "required": true,
@@ -699,7 +700,7 @@
       ],
       "post": {
         "summary": "Cosine-similarity search; supply either query_text (auto-embedded) or query_vector",
-        "description": "Supply either `query_text` or `query_vector`. **For low latency, prefer `query_vector`.** `query_text` requires a synchronous server-side embedding round-trip to the configured provider, which typically dominates request latency (seconds for large models). Clients issuing many searches (e.g. an LLM/n8n agent) should embed once on their side and pass `query_vector` to skip that step entirely; cosine search itself is sub-millisecond.",
+        "description": "Supply either `query_text` or `query_vector`. **For low latency, prefer `query_vector`.** `query_text` requires a synchronous server-side embedding round-trip to the configured provider, which typically dominates request latency (seconds for large models). Clients issuing many searches (e.g. an LLM/n8n agent) should embed once on their side and pass `query_vector` to skip that step entirely; cosine search itself is sub-millisecond. Send `Cache-Control: no-store` to bypass the server-side embedding cache: the query text will be re-embedded fresh and the result will not be stored.",
         "operationId": "searchVectors",
         "requestBody": {
           "required": true,

+ 13 - 5
src/handlers/vectors.cpp

@@ -16,8 +16,13 @@ CollectionMeta requireVectorCollection(ServerDeps* d, const httplib::Request& re
     if (m->kind != "vector") throw ApiError(ErrCode::Unprocessable, "not_vector", "collection is not a vector collection");
     return *m;
 }
+bool wantsNoStore(const httplib::Request& req) {
+    if (!req.has_header("Cache-Control")) return false;
+    const std::string cc = req.get_header_value("Cache-Control");
+    return cc.find("no-store") != std::string::npos;
+}
 std::vector<float> resolveVector(ServerDeps* d, const CollectionMeta& meta, const nlohmann::json& body,
-                                 const char* vecField, const char* textField) {
+                                 const char* vecField, const char* textField, bool noStore) {
     std::vector<float> v;
     if (body.contains(vecField) && body[vecField].is_array()) {
         v = body[vecField].get<std::vector<float>>();
@@ -26,16 +31,19 @@ std::vector<float> resolveVector(ServerDeps* d, const CollectionMeta& meta, cons
         std::string model = meta.embeddingModel.empty() ? snap->defaultEmbeddingModel : meta.embeddingModel;
         // 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.
+        // When noStore is true (Cache-Control: no-store), skip both the cache get and put.
         const std::string text = body[textField].get<std::string>();
         EmbeddingCache::Key key{snap->openaiApiBase, model, text};
-        if (auto hit = embeddingCache().get(key)) {
+        std::optional<std::vector<float>> hit;
+        if (!noStore) hit = embeddingCache().get(key);
+        if (hit) {
             v = std::move(*hit);
         } else {
             EmbeddingClient emb(snap->openaiApiBase, snap->openaiApiKey,
                                 snap->embeddingConnectTimeoutSec, snap->embeddingReadTimeoutSec,
                                 snap->embeddingClientPoolSize);
             v = emb.embed(model, text);
-            embeddingCache().put(key, v);  // copy, not move: v is still needed for the dimension check below
+            if (!noStore) embeddingCache().put(key, v);  // copy, not move: v is still needed for the dimension check below
         }
     } else {
         throw ApiError(ErrCode::Unprocessable, "validation",
@@ -53,7 +61,7 @@ void registerVectorRoutes(ApiServer& s) {
     svr.Post(R"(/api/v1/projects/([^/]+)/collections/([^/]+)/vectors)", [d](const httplib::Request& req, httplib::Response& res) {
         std::string project, name; CollectionMeta meta = requireVectorCollection(d, req, project, name);
         auto body = bodyJson(req);
-        std::vector<float> vec = resolveVector(d, meta, body, "vector", "text");
+        std::vector<float> vec = resolveVector(d, meta, body, "vector", "text", wantsNoStore(req));
         nlohmann::json doc = body.value("metadata", nlohmann::json::object());
         if (!doc.is_object()) doc = nlohmann::json::object();
         if (body.contains("text")) doc["text"] = body["text"];
@@ -66,7 +74,7 @@ void registerVectorRoutes(ApiServer& s) {
     svr.Post(R"(/api/v1/projects/([^/]+)/collections/([^/]+)/search)", [d](const httplib::Request& req, httplib::Response& res) {
         std::string project, name; CollectionMeta meta = requireVectorCollection(d, req, project, name);
         auto body = bodyJson(req);
-        std::vector<float> q = resolveVector(d, meta, body, "query_vector", "query_text");
+        std::vector<float> q = resolveVector(d, meta, body, "query_vector", "query_text", wantsNoStore(req));
         uint32_t topK = body.value("top_k", 5u);
         float minScore = body.value("min_score", 0.0f);
         auto results = d->db.client().similaritySearch(qualify(project, name), q, topK, minScore);

+ 60 - 0
tests/test_api_integration.cpp

@@ -452,3 +452,63 @@ TEST_F(ApiFixture, EmbeddingCacheDeduplicatesIdenticalQueryText) {
     countingMock.stop();
     countingThread.join();
 }
+
+// D.1: Cache-Control: no-store bypasses the embedding cache on BOTH gets and puts.
+// Two identical query_text searches each carrying the header must each reach the upstream
+// embedding endpoint (i.e. count == 2, not 1).
+TEST_F(ApiFixture, EmbeddingCacheBypassedByNoCacheControlHeader) {
+    // 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","nostore_test"},{"kind","vector"},{"vector_dimension",mockDim_}}.dump(),
+        "application/json")->status, 201);
+
+    // POST /search twice with the same query_text AND Cache-Control: no-store each time.
+    const std::string searchPath = base + "/nostore_test/search";
+    const nlohmann::json searchBody = {{"query_text", "cache bypass test"}, {"top_k", 5}};
+
+    httplib::Client cc("127.0.0.1", port_);
+    cc.set_default_headers({{"Authorization", "Bearer " + adminKey_},
+                             {"Cache-Control", "no-store"}});
+
+    auto r1 = cc.Post(searchPath.c_str(), searchBody.dump(), "application/json");
+    ASSERT_TRUE(r1); EXPECT_EQ(r1->status, 200);
+
+    auto r2 = cc.Post(searchPath.c_str(), searchBody.dump(), "application/json");
+    ASSERT_TRUE(r2); EXPECT_EQ(r2->status, 200);
+
+    // The upstream mock must have been called exactly TWICE (cache bypassed both times).
+    EXPECT_EQ(embReqCount.load(), 2)
+        << "expected exactly 2 upstream embedding requests when Cache-Control: no-store is set";
+
+    // Cleanup.
+    c.Delete((base + "/nostore_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();
+}