|
@@ -1477,3 +1477,132 @@ TEST_F(ApiFixture, SessionEndpointAcceptsABearerKeyToo) {
|
|
|
ASSERT_TRUE(r); ASSERT_EQ(r->status, 200);
|
|
ASSERT_TRUE(r); ASSERT_EQ(r->status, 200);
|
|
|
EXPECT_TRUE(nlohmann::json::parse(r->body)["admin"].get<bool>());
|
|
EXPECT_TRUE(nlohmann::json::parse(r->body)["admin"].get<bool>());
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+// --- metadata filters on vector search ----------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+namespace {
|
|
|
|
|
+// Two orthogonal-ish unit vectors so the ranking is unambiguous, plus a query
|
|
|
|
|
+// that is closest to "red".
|
|
|
|
|
+const nlohmann::json RED_VEC = {1.0, 0.0, 0.0, 0.0};
|
|
|
|
|
+const nlohmann::json YELLOW_VEC = {0.0, 1.0, 0.0, 0.0};
|
|
|
|
|
+const nlohmann::json QUERY_VEC = {0.9, 0.4, 0.0, 0.0};
|
|
|
|
|
+
|
|
|
|
|
+std::string seedColourCollection(ApiFixture& f, httplib::Client& c, const std::string& project,
|
|
|
|
|
+ const std::string& coll, int dim) {
|
|
|
|
|
+ std::string base = "/api/v1/projects/" + project + "/collections";
|
|
|
|
|
+ EXPECT_EQ(c.Post(base.c_str(),
|
|
|
|
|
+ nlohmann::json{{"name", coll}, {"kind", "vector"}, {"vector_dimension", dim}}.dump(),
|
|
|
|
|
+ "application/json")->status, 201);
|
|
|
|
|
+ EXPECT_EQ(c.Post((base + "/" + coll + "/vectors").c_str(),
|
|
|
|
|
+ nlohmann::json{{"id","a"},{"vector",RED_VEC},{"metadata",{{"colour","red"},{"n",1}}}}.dump(),
|
|
|
|
|
+ "application/json")->status, 201);
|
|
|
|
|
+ EXPECT_EQ(c.Post((base + "/" + coll + "/vectors").c_str(),
|
|
|
|
|
+ nlohmann::json{{"id","b"},{"vector",YELLOW_VEC},{"metadata",{{"colour","yellow"},{"n",2}}}}.dump(),
|
|
|
|
|
+ "application/json")->status, 201);
|
|
|
|
|
+ return base + "/" + coll + "/search";
|
|
|
|
|
+}
|
|
|
|
|
+} // namespace
|
|
|
|
|
+
|
|
|
|
|
+TEST_F(ApiFixture, VectorSearchFiltersRestrictResults) {
|
|
|
|
|
+ auto c = admin();
|
|
|
|
|
+ std::string search = seedColourCollection(*this, c, project_, "colours", 4);
|
|
|
|
|
+
|
|
|
|
|
+ auto run = [&](nlohmann::json filters) {
|
|
|
|
|
+ nlohmann::json body{{"query_vector", QUERY_VEC}, {"top_k", 10}};
|
|
|
|
|
+ if (!filters.is_null()) body["filters"] = filters;
|
|
|
|
|
+ auto r = c.Post(search.c_str(), body.dump(), "application/json");
|
|
|
|
|
+ EXPECT_EQ(r->status, 200);
|
|
|
|
|
+ return nlohmann::json::parse(r->body)["results"];
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ EXPECT_EQ(run(nullptr).size(), 2u) << "no filter: both vectors";
|
|
|
|
|
+ auto red = run(nlohmann::json::array({"colour:eq:red"}));
|
|
|
|
|
+ ASSERT_EQ(red.size(), 1u) << "colour:eq:red must return only the red vector";
|
|
|
|
|
+ EXPECT_EQ(red[0]["id"], "a");
|
|
|
|
|
+ EXPECT_EQ(run(nlohmann::json::array({"colour:eq:blue"})).size(), 0u) << "no match must return nothing";
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+TEST_F(ApiFixture, VectorSearchFiltersSupportTheDocumentOperators) {
|
|
|
|
|
+ auto c = admin();
|
|
|
|
|
+ std::string search = seedColourCollection(*this, c, project_, "ops", 4);
|
|
|
|
|
+ auto count = [&](const std::string& spec) {
|
|
|
|
|
+ auto r = c.Post(search.c_str(),
|
|
|
|
|
+ nlohmann::json{{"query_vector", QUERY_VEC}, {"top_k", 10},
|
|
|
|
|
+ {"filters", nlohmann::json::array({spec})}}.dump(), "application/json");
|
|
|
|
|
+ EXPECT_EQ(r->status, 200) << spec << " -> " << r->body;
|
|
|
|
|
+ return nlohmann::json::parse(r->body)["results"].size();
|
|
|
|
|
+ };
|
|
|
|
|
+ EXPECT_EQ(count("colour:ne:red"), 1u);
|
|
|
|
|
+ EXPECT_EQ(count("n:gt:1"), 1u);
|
|
|
|
|
+ EXPECT_EQ(count("n:gte:1"), 2u);
|
|
|
|
|
+ EXPECT_EQ(count("n:lt:2"), 1u);
|
|
|
|
|
+ EXPECT_EQ(count("colour:in:[\"red\",\"yellow\"]"), 2u);
|
|
|
|
|
+ // `search` is the substring operator; `contains` is array-membership, and
|
|
|
|
|
+ // both behave exactly as they do on /documents because the DB evaluates them.
|
|
|
|
|
+ EXPECT_EQ(count("colour:search:ell"), 1u);
|
|
|
|
|
+ EXPECT_EQ(count("colour:contains:yellow"), 0u);
|
|
|
|
|
+ EXPECT_EQ(count("colour:exists:true"), 2u);
|
|
|
|
|
+ EXPECT_EQ(count("missing_field:exists:true"), 0u);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+TEST_F(ApiFixture, VectorSearchFiltersCombineWithTopKAndMinScore) {
|
|
|
|
|
+ auto c = admin();
|
|
|
|
|
+ std::string search = seedColourCollection(*this, c, project_, "combo", 4);
|
|
|
|
|
+ // top_k still caps a filtered result set.
|
|
|
|
|
+ auto r = c.Post(search.c_str(),
|
|
|
|
|
+ nlohmann::json{{"query_vector", QUERY_VEC}, {"top_k", 1},
|
|
|
|
|
+ {"filters", nlohmann::json::array({"colour:exists:true"})}}.dump(),
|
|
|
|
|
+ "application/json");
|
|
|
|
|
+ ASSERT_EQ(r->status, 200);
|
|
|
|
|
+ auto res = nlohmann::json::parse(r->body)["results"];
|
|
|
|
|
+ ASSERT_EQ(res.size(), 1u);
|
|
|
|
|
+ EXPECT_EQ(res[0]["id"], "a") << "the nearer vector must win, not simply the first match";
|
|
|
|
|
+
|
|
|
|
|
+ // min_score still applies on top of the filter.
|
|
|
|
|
+ auto r2 = c.Post(search.c_str(),
|
|
|
|
|
+ nlohmann::json{{"query_vector", QUERY_VEC}, {"top_k", 10}, {"min_score", 0.99},
|
|
|
|
|
+ {"filters", nlohmann::json::array({"colour:exists:true"})}}.dump(),
|
|
|
|
|
+ "application/json");
|
|
|
|
|
+ ASSERT_EQ(r2->status, 200);
|
|
|
|
|
+ EXPECT_EQ(nlohmann::json::parse(r2->body)["results"].size(), 0u);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+TEST_F(ApiFixture, VectorSearchRejectsMalformedFilters) {
|
|
|
|
|
+ auto c = admin();
|
|
|
|
|
+ std::string search = seedColourCollection(*this, c, project_, "bad", 4);
|
|
|
|
|
+ // A filter the server cannot parse must fail loudly rather than be ignored.
|
|
|
|
|
+ for (const char* spec : {"colour", "colour:nosuchop:red"}) {
|
|
|
|
|
+ auto r = c.Post(search.c_str(),
|
|
|
|
|
+ nlohmann::json{{"query_vector", QUERY_VEC}, {"top_k", 5},
|
|
|
|
|
+ {"filters", nlohmann::json::array({spec})}}.dump(), "application/json");
|
|
|
|
|
+ EXPECT_GE(r->status, 400) << spec << " should be rejected";
|
|
|
|
|
+ EXPECT_LT(r->status, 500) << spec << " should be a client error";
|
|
|
|
|
+ }
|
|
|
|
|
+ // Wrong type for the field entirely.
|
|
|
|
|
+ auto r = c.Post(search.c_str(),
|
|
|
|
|
+ nlohmann::json{{"query_vector", QUERY_VEC}, {"filters", "colour:eq:red"}}.dump(),
|
|
|
|
|
+ "application/json");
|
|
|
|
|
+ EXPECT_GE(r->status, 400);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+TEST_F(ApiFixture, VectorSearchFilteredScoresMatchUnfilteredScores) {
|
|
|
|
|
+ // A filtered search must report the same score for a document as an
|
|
|
|
|
+ // unfiltered one, otherwise min_score means two different things.
|
|
|
|
|
+ auto c = admin();
|
|
|
|
|
+ std::string search = seedColourCollection(*this, c, project_, "scores", 4);
|
|
|
|
|
+ auto scoreOf = [&](nlohmann::json body, const std::string& id) -> double {
|
|
|
|
|
+ auto r = c.Post(search.c_str(), body.dump(), "application/json");
|
|
|
|
|
+ EXPECT_EQ(r->status, 200);
|
|
|
|
|
+ // Bind before iterating: a range-for over json::parse(...)[...] walks a
|
|
|
|
|
+ // destroyed temporary (see CLAUDE.md).
|
|
|
|
|
+ const nlohmann::json parsed = nlohmann::json::parse(r->body);
|
|
|
|
|
+ for (const auto& h : parsed["results"])
|
|
|
|
|
+ if (h["id"] == id) return h["score"].get<double>();
|
|
|
|
|
+ return -1.0;
|
|
|
|
|
+ };
|
|
|
|
|
+ double plain = scoreOf({{"query_vector", QUERY_VEC}, {"top_k", 10}}, "a");
|
|
|
|
|
+ double filtered = scoreOf({{"query_vector", QUERY_VEC}, {"top_k", 10},
|
|
|
|
|
+ {"filters", nlohmann::json::array({"colour:eq:red"})}}, "a");
|
|
|
|
|
+ ASSERT_GT(plain, 0.0);
|
|
|
|
|
+ EXPECT_NEAR(plain, filtered, 1e-6);
|
|
|
|
|
+}
|