Pārlūkot izejas kodu

feat(api): project-scoped vector store + similarity search with embedding generation

Fszontagh 2 mēneši atpakaļ
vecāks
revīzija
e1a7ad11a9
2 mainītis faili ar 90 papildinājumiem un 1 dzēšanām
  1. 65 1
      src/handlers/vectors.cpp
  2. 25 0
      tests/test_api_integration.cpp

+ 65 - 1
src/handlers/vectors.cpp

@@ -1,2 +1,66 @@
+#include "collection_registry.hpp"
+#include "embeddings.hpp"
+#include "errors.hpp"
+#include "json_http.hpp"
 #include "server.hpp"
-namespace svapi { void registerVectorRoutes(ApiServer&) {} }
+namespace svapi {
+namespace {
+CollectionMeta requireVectorCollection(ServerDeps* d, const httplib::Request& req,
+                                        std::string& projectOut, std::string& nameOut) {
+    ApiKey k = requireKey(*d, req);
+    projectOut = req.matches[1]; nameOut = req.matches[2];
+    requireProjectAccess(k, projectOut);
+    auto m = d->registry.get(projectOut, nameOut);
+    if (!m) throw ApiError(ErrCode::NotFound, "not_found", "no such collection");
+    if (m->kind != "vector") throw ApiError(ErrCode::Unprocessable, "not_vector", "collection is not a vector collection");
+    return *m;
+}
+std::vector<float> resolveVector(ServerDeps* d, const CollectionMeta& meta, const nlohmann::json& body,
+                                 const char* vecField, const char* textField) {
+    std::vector<float> v;
+    if (body.contains(vecField) && body[vecField].is_array()) {
+        v = body[vecField].get<std::vector<float>>();
+    } 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);
+        v = emb.embed(model, body[textField].get<std::string>());
+    } else {
+        throw ApiError(ErrCode::Unprocessable, "validation",
+                       std::string("provide '") + vecField + "' or '" + textField + "'");
+    }
+    if (v.size() != meta.vectorDimension)
+        throw ApiError(ErrCode::Unprocessable, "dimension_mismatch",
+                       "expected dimension " + std::to_string(meta.vectorDimension) + ", got " + std::to_string(v.size()));
+    return v;
+}
+}
+void registerVectorRoutes(ApiServer& s) {
+    auto& svr = s.raw(); ServerDeps* d = &s.deps();
+
+    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");
+        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"];
+        doc["_vector"] = vec;
+        std::string newId = d->db.client().insert(qualify(project, name), doc, body.value("id", ""));
+        if (newId.empty()) throw ApiError(ErrCode::Unavailable, "db_error", "insert failed");
+        sendJson(res, 201, {{"id", newId}});
+    });
+
+    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");
+        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);
+        nlohmann::json arr = nlohmann::json::array();
+        for (const auto& r : results) arr.push_back({{"id", r.id}, {"score", r.score}, {"data", r.data}});
+        sendJson(res, 200, {{"results", arr}});
+    });
+}
+}

+ 25 - 0
tests/test_api_integration.cpp

@@ -116,3 +116,28 @@ TEST_F(ApiFixture, DocumentCrudAndFind) {
     EXPECT_EQ(c.Delete((docs + "/" + id).c_str())->status, 200);
     EXPECT_EQ(c.Get((docs + "/" + id).c_str())->status, 404);
 }
+
+TEST_F(ApiFixture, VectorStoreFromTextThenSearch) {
+    auto c = admin();
+    std::string base = "/api/v1/projects/" + project_ + "/collections";
+    ASSERT_EQ(c.Post(base.c_str(), nlohmann::json{{"name","rag"},{"kind","vector"},{"vector_dimension",mockDim_}}.dump(),"application/json")->status, 201);
+    EXPECT_EQ(c.Post((base + "/rag/vectors").c_str(),
+        nlohmann::json{{"text","dark mode"},{"metadata",{{"src","n8n"}}}}.dump(), "application/json")->status, 201);
+    auto sr = c.Post((base + "/rag/search").c_str(),
+        nlohmann::json{{"query_text","dark mode"},{"top_k",5}}.dump(), "application/json");
+    ASSERT_EQ(sr->status, 200);
+    auto srJson = nlohmann::json::parse(sr->body);
+    EXPECT_GE(srJson["results"].size(), 1u);
+}
+TEST_F(ApiFixture, VectorWrongDimensionIs422) {
+    auto c = admin();
+    std::string base = "/api/v1/projects/" + project_ + "/collections";
+    ASSERT_EQ(c.Post(base.c_str(), nlohmann::json{{"name","rag2"},{"kind","vector"},{"vector_dimension",mockDim_}}.dump(),"application/json")->status, 201);
+    EXPECT_EQ(c.Post((base + "/rag2/vectors").c_str(), nlohmann::json{{"vector",{1.0,2.0}}}.dump(), "application/json")->status, 422);
+}
+TEST_F(ApiFixture, VectorOnJsonCollectionIs422) {
+    auto c = admin();
+    std::string base = "/api/v1/projects/" + project_ + "/collections";
+    ASSERT_EQ(c.Post(base.c_str(), nlohmann::json{{"name","plain"},{"kind","json"}}.dump(),"application/json")->status, 201);
+    EXPECT_EQ(c.Post((base + "/plain/vectors").c_str(), nlohmann::json{{"vector",{1,2,3,4}}}.dump(), "application/json")->status, 422);
+}