Skip to content

Commit 8397071

Browse files
jak-panclaude
andauthored
fix(sqlengine): keep the exact FLOAT and DOUBLE bound in a forward filter (#818)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 89ba922 commit 8397071

2 files changed

Lines changed: 110 additions & 2 deletions

File tree

‎src/db/sqlengine/analyzer/query_info_helper.cc‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
// limitations under the License.
1414

1515
#include "query_info_helper.h"
16+
#include <cstdio>
17+
#include <limits>
1618
#include <memory>
1719
#include <zvec/ailego/logger/logger.h>
1820
#include <zvec/ailego/utility/string_helper.h>
@@ -80,6 +82,16 @@ bool QueryInfoHelper::text_2_data_buf(const std::string &text,
8082
return false;
8183
}
8284

85+
// StringHelper::ToString() keeps six decimals. Print enough significant
86+
// digits that the text reads back as the same value.
87+
template <typename T>
88+
static std::string exact_text(T val) {
89+
char buf[32];
90+
std::snprintf(buf, sizeof(buf), "%.*g", std::numeric_limits<T>::max_digits10,
91+
static_cast<double>(val));
92+
return buf;
93+
}
94+
8395
bool QueryInfoHelper::data_buf_2_text(const std::string &data_buf,
8496
zvec::DataType data_type,
8597
std::string *text) {
@@ -104,12 +116,12 @@ bool QueryInfoHelper::data_buf_2_text(const std::string &data_buf,
104116
}
105117

106118
if (data_type == zvec::DataType::FLOAT) {
107-
*text = ailego::StringHelper::ToString(*(float *)data_buf.data());
119+
*text = exact_text(*(float *)data_buf.data());
108120
return true;
109121
}
110122

111123
if (data_type == zvec::DataType::DOUBLE) {
112-
*text = ailego::StringHelper::ToString(*(double *)data_buf.data());
124+
*text = exact_text(*(double *)data_buf.data());
113125
return true;
114126
}
115127

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
// Copyright 2025-present the zvec project
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
// A range filter on an indexed FLOAT or DOUBLE field must return the same
16+
// documents whether a segment runs it on the inverted index or, after the
17+
// optimizer moved it, as a forward filter. The first segment is sealed and
18+
// its range covers most of its values, so the optimizer moves the filter to a
19+
// forward scan there; the writing segment keeps the inverted index.
20+
21+
#include <algorithm>
22+
#include <filesystem>
23+
#include <iostream>
24+
#include <memory>
25+
#include <string>
26+
#include <vector>
27+
#include <gtest/gtest.h>
28+
#include <zvec/ailego/buffer/block_eviction_queue.h>
29+
#include <zvec/db/collection.h>
30+
#include <zvec/db/index_params.h>
31+
32+
using namespace zvec;
33+
34+
namespace {
35+
36+
size_t QueryCount(const Collection::Ptr &collection,
37+
const std::string &filter) {
38+
SearchQuery query;
39+
query.filter_ = filter;
40+
query.topk_ = 2000;
41+
auto result = collection->query(query);
42+
EXPECT_TRUE(result.has_value()) << filter;
43+
if (!result.has_value()) return 0;
44+
std::cout << filter << " -> " << result.value().size() << " documents\n";
45+
return result.value().size();
46+
}
47+
48+
} // namespace
49+
50+
TEST(FloatRangeFilter, ForwardScanKeepsExactBound) {
51+
ailego::MemoryLimitPool::get_instance().init(256 * 1024 * 1024);
52+
const std::string path = "./collection_float_range_filter_dir";
53+
std::filesystem::remove_all(path);
54+
55+
CollectionSchema schema("float_range_filter");
56+
schema.set_max_doc_count_per_segment(1000);
57+
ASSERT_TRUE(schema
58+
.add_field(std::make_shared<FieldSchema>(
59+
"price", DataType::FLOAT, false,
60+
std::make_shared<InvertIndexParams>()))
61+
.ok());
62+
ASSERT_TRUE(schema
63+
.add_field(std::make_shared<FieldSchema>(
64+
"weight", DataType::DOUBLE, false,
65+
std::make_shared<InvertIndexParams>()))
66+
.ok());
67+
auto opened = Collection::CreateAndOpen(path, schema, CollectionOptions{});
68+
ASSERT_TRUE(opened.has_value()) << opened.error().message();
69+
auto collection = std::move(opened.value());
70+
71+
// 50 documents sit exactly on the bounds used below, 951 above them. The
72+
// 1001st document starts a new segment, so the first 1000 are sealed.
73+
std::vector<Doc> docs;
74+
for (int i = 0; i < 1001; ++i) {
75+
Doc doc;
76+
doc.set_pk(std::to_string(i));
77+
doc.set<float>("price", i < 50 ? 1.0000001f : 2.0f);
78+
doc.set<double>("weight", i < 50 ? 1.0000000001 : 2.0);
79+
docs.push_back(std::move(doc));
80+
}
81+
for (size_t start = 0; start < docs.size(); start += 500) {
82+
std::vector<Doc> batch(docs.begin() + start,
83+
docs.begin() + std::min(docs.size(), start + 500));
84+
auto inserted = collection->insert(batch);
85+
ASSERT_TRUE(inserted.has_value()) << inserted.error().message();
86+
for (const auto &status : inserted.value()) {
87+
ASSERT_TRUE(status.ok()) << status.message();
88+
}
89+
}
90+
91+
EXPECT_EQ(QueryCount(collection, "price > 1.0000001"), 951u);
92+
EXPECT_EQ(QueryCount(collection, "weight > 1.0000000001"), 951u);
93+
94+
collection.reset();
95+
std::filesystem::remove_all(path);
96+
}

0 commit comments

Comments
 (0)