Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions docs/hgraph.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,10 +225,21 @@ means that the index uses PQ quantization with 64 subspaces, enables reordering
- **Optional Values**: 1 to INT_MAX
- **Default Value**: Must be provided (no default value)

### max_duplicates_per_group
- **Parameter Type**: int
- **Parameter Description**: Maximum number of additional duplicate IDs returned for each
duplicate group during KNN graph search
- **Optional Values**: -1 (unlimited), 0 (do not expand duplicate IDs), or any positive integer
- **Default Value**: -1
- **Notes**: Only applies when `support_duplicate` is enabled; range search keeps its existing
expansion behavior

## Examples for Search Parameter String
```json
"hgraph": {
"ef_search": 200
"ef_search": 200,
"max_duplicates_per_group": 2
}
```
means that the search will use an ef_search value of 200 to control the search quality and performance trade-off.
means that the search uses an ef_search value of 200 and returns at most two additional IDs from
each duplicate group.
1 change: 1 addition & 0 deletions include/vsag/constants.h
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ extern const char* const HGRAPH_PRECISE_IO_TYPE;
extern const char* const HGRAPH_PRECISE_FILE_PATH;
extern const char* const HGRAPH_PARAMETER_EF_RUNTIME;
extern const char* const HGRAPH_PARAMETER_HOPS_LIMIT;
extern const char* const HGRAPH_PARAMETER_MAX_DUPLICATES_PER_GROUP;
extern const char* const HGRAPH_EXTRA_INFO_SIZE;
extern const char* const HGRAPH_SUPPORT_DUPLICATE;
extern const char* const HGRAPH_DUPLICATE_DISTANCE_THRESHOLD;
Expand Down
69 changes: 66 additions & 3 deletions src/algorithm/hgraph.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -933,10 +933,20 @@ HGraph::KnnSearch(const DatasetPtr& query,
auto search_result = DistanceHeap::MakeInstanceBySize<true, false>(ctx.alloc, k);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[note] Test - please ignore

const auto* query_data = get_data(query);
if (is_last_filter) {
if (const auto* pending_duplicates = iter_filter_ctx->GetPendingDuplicates();
pending_duplicates != nullptr) {
for (const auto& [pending_id, pending_dist] : *pending_duplicates) {
if (iter_filter_ctx->CheckPoint(pending_id)) {
search_result->Push(pending_dist, pending_id);
}
}
}
while (!iter_filter_ctx->Empty()) {
uint32_t cur_inner_id = iter_filter_ctx->GetTopID();
float cur_dist = iter_filter_ctx->GetTopDist();
search_result->Push(cur_dist, cur_inner_id);
if (not iter_filter_ctx->IsPendingDuplicate(cur_inner_id)) {
search_result->Push(cur_dist, cur_inner_id);
}
iter_filter_ctx->PopDiscard();
}
} else {
Expand Down Expand Up @@ -964,6 +974,7 @@ HGraph::KnnSearch(const DatasetPtr& query,
search_param.is_inner_id_allowed = ft;
search_param.topk = static_cast<int64_t>(search_param.ef);
search_param.consider_duplicate = this->label_table_->CompressDuplicateData();
search_param.max_duplicates_per_group = params.max_duplicates_per_group;
search_param.parallel_search_thread_count = params.parallel_search_thread_count;
search_param.min_distance = params.min_distance;

Expand Down Expand Up @@ -1410,6 +1421,9 @@ HGraph::Serialize(StreamWriter& writer) const {
if (this->use_attribute_filter_ and this->attr_filter_index_ != nullptr) {
this->attr_filter_index_->Serialize(writer);
}
if (this->label_table_->CompressDuplicateData()) {
this->label_table_->SerializeDuplicateRecords(writer);
}
return;
}

Expand Down Expand Up @@ -1442,10 +1456,53 @@ HGraph::Serialize(StreamWriter& writer) const {
footer->Write(writer);
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[note] The reader.GetCursor() != cursor check after deserialize() is redundant: ReadFuncStreamReader always increments its cursor by the exact read size, and cursor is only incremented by the same amount in the lambda. Since deserialize() only reads through the reader, the two cursors can only diverge if deserialize() seeks the reader — but seeking throws UNSUPPORTED_INDEX_OPERATION in the v0.14 path. The check is harmless as a defense-in-depth measure, just noting it is logically unreachable.


void
HGraph::Deserialize(std::istream& in_stream) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[note] The Deserialize(std::istream& in_stream) overload gates on this->use_old_serial_format_ to decide between footer-based deserialization and forced v0.14 mode. This is correct for the intended use case (forward-only streams from legacy format), but it means an index created with use_old_serial_format_=true will always treat any std::istream input as v0.14 format, even if the stream contains a native-format blob.

This is acceptable because:

  1. The std::istream overload is specifically for forward-only streams where footer detection is impossible (no seek support).
  2. The Deserialize(StreamReader&) overload still uses footer auto-detection via deserialize(reader, false).
  3. The test ForwardOnlyStringBuffer confirms that seek is never attempted on the stream.

However, if someone accidentally passes a native-format blob through Deserialize(std::istream&) on an index with use_old_serial_format_=true, the deserialization will fail with a parse error rather than silently producing wrong results — which is the safe failure mode.

if (not this->use_old_serial_format_) {
InnerIndexInterface::Deserialize(in_stream);
return;
}

try {
uint64_t cursor = 0;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] The Deserialize(std::istream&) override catches std::bad_alloc and re-throws as VsagException, but the read_func lambda and this->deserialize(reader, true) can throw other exception types (e.g. VsagException from the read lambda on short reads, or from deserialize_basic_info_v0_14). These exceptions propagate through the lambda but are not caught here.

Since the try/catch block only handles std::bad_alloc, consider either:

  • Removing the try/catch entirely (let all exceptions propagate naturally), or
  • Adding a catch-all that wraps unexpected exceptions in VsagException.

The current partial catch gives a false impression of comprehensive error handling.

auto read_func = [&](uint64_t offset, uint64_t size, void* data) {
if (offset != cursor) {
throw VsagException(ErrorType::UNSUPPORTED_INDEX_OPERATION,
"v0.14 sequential stream does not support seek");
}
in_stream.read(static_cast<char*>(data), static_cast<int64_t>(size));
if (in_stream.gcount() != static_cast<int64_t>(size)) {
throw VsagException(
ErrorType::READ_ERROR,
fmt::format("Attempted to read: {} bytes. Remaining content size: {} bytes.",
size,
in_stream.gcount()));
}
cursor += size;
};
ReadFuncStreamReader reader(read_func, 0, 0);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[note] The ReadFuncStreamReader is constructed with length=0, which means StreamReader::Length() will return 0. While the v0.14 deserialization path does not appear to call Length() on the reader, if any future code path or the DeserializeDuplicateRecords implementation were to rely on Length() for bounds checking, it would get a misleading value of 0.

Consider either passing the actual stream size if available, or adding a comment noting that Length() is intentionally 0 and must not be relied upon in this code path.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[note] ReadFuncStreamReader is constructed with length=0 at line 1483 (ReadFuncStreamReader reader(read_func, 0, 0)). Looking at the ReadFuncStreamReader implementation, passing length=0 means the reader has no known total size. This is correct for forward-only streams (like std::istream) where the total size is unknown upfront, but it means StreamReader::ReadVector and other size-dependent operations may not be able to pre-allocate. The v0.14 deserialization path handles this by reading elements one-at-a-time, so this is safe. Consider adding a brief comment explaining why length=0 is intentional here for future readers.

this->deserialize(reader, true);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[note] The ReadFuncStreamReader is constructed with (read_func, 0, 0) where the third argument 0 is the stream length stored in the base StreamReader. Since v0.14 deserialization is forward-only (no seeking), Length() is never called, so this is safe. However, if future code in the deserialize(reader, true) path ever calls reader.Length(), it would return 0 which could cause subtle issues. Consider passing a sentinel value like std::numeric_limits<uint64_t>::max() to make the "unknown length" semantics explicit, or adding a comment noting that length is unused for forward-only streams.

if (reader.GetCursor() != cursor) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] The dual-cursor tracking between the lambda-captured cursor and ReadFuncStreamReader internal cursor_ is fragile. If ReadFuncStreamReader::Seek is ever called during v0.14 deserialization (e.g., by a future code change), only the internal cursor_ would be updated, causing this check to incorrectly throw UNSUPPORTED_INDEX_OPERATION.

The post-deserialization check could rely solely on reader.GetCursor() since ReadFuncStreamReader::Read already updates its internal cursor after calling read_func_. The outer cursor variable is redundant for the final consistency check. Alternatively, if the outer cursor is kept for the read_func seek-detection, consider adding a comment explaining that ReadFuncStreamReader::Seek must never be called in the v0.14 path.

throw VsagException(ErrorType::UNSUPPORTED_INDEX_OPERATION,
"v0.14 sequential stream does not support seek");
}
} catch (const std::bad_alloc& e) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[note] The catch (const std::bad_alloc&) only wraps the body after ReadFuncStreamReader construction. If ReadFuncStreamReader's constructor throws (e.g., from std::function copy allocating memory), the exception would propagate as std::bad_alloc rather than being translated to VsagException. This is likely fine in practice since callers should handle std::bad_alloc anyway, but it is inconsistent with the explicit translation intent shown here.

throw VsagException(ErrorType::NO_ENOUGH_MEMORY, "failed to Deserialize: ", e.what());

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[note] The catch (const std::bad_alloc&) in Deserialize(std::istream&) only converts std::bad_alloc to VsagException. The fmt::format calls inside the read_func lambda could also throw std::bad_alloc (which would be caught), but any VsagException thrown by the lambda (for seek/read errors) propagates directly to the caller without being caught here. This is correct behavior since those are different error types, but the narrow catch scope means the method has two distinct exception propagation paths. Consider whether a catch-all that rethrows non-OOM exceptions would make the error handling intent clearer.

}
}

void
HGraph::Deserialize(StreamReader& reader) {
// try to deserialize footer (only in new version)
auto footer = Footer::Parse(reader);
this->deserialize(reader, false);
}

void
HGraph::deserialize(StreamReader& reader, bool force_v0_14) {
FooterPtr footer = nullptr;
if (not force_v0_14) {
// try to deserialize footer (only in new version)
footer = Footer::Parse(reader);
}

if (footer == nullptr) { // old format, DON'T EDIT, remove in the future
logger::debug("parse with v0.14 version format");
Expand Down Expand Up @@ -1474,6 +1531,11 @@ HGraph::Deserialize(StreamReader& reader) {
if (this->use_attribute_filter_ and this->attr_filter_index_ != nullptr) {
this->attr_filter_index_->Deserialize(reader);
}
if (this->label_table_->CompressDuplicateData()) {
const auto logical_element_count =
Comment thread
jac0626 marked this conversation as resolved.
static_cast<uint64_t>(this->label_table_->GetTotalCount());
this->label_table_->DeserializeDuplicateRecords(reader, logical_element_count);
Comment thread
jac0626 marked this conversation as resolved.
}
} else { // create like `else if ( ver in [v0.15, v0.17] )` here if need in the future
logger::debug("parse with new version format");

Expand Down Expand Up @@ -2222,6 +2284,7 @@ HGraph::SearchWithRequest(const SearchRequest& request) const {
search_param.topk, static_cast<int64_t>(static_cast<float>(k) * params.topk_factor));
}
search_param.consider_duplicate = true;
search_param.max_duplicates_per_group = params.max_duplicates_per_group;
if (params.enable_time_record) {
search_param.time_cost = std::make_shared<Timer>();
search_param.time_cost->SetThreshold(params.timeout_ms);
Expand Down
7 changes: 7 additions & 0 deletions src/algorithm/hgraph.h
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

#pragma once

#include <istream>
#include <random>
#include <shared_mutex>
#include <string>
Expand Down Expand Up @@ -79,6 +80,9 @@ class HGraph : public InnerIndexInterface {
DatasetPtr
CalDistanceById(const float* query, const int64_t* ids, int64_t count) const override;

void
Deserialize(std::istream& in_stream) override;

void
Deserialize(StreamReader& reader) override;

Expand Down Expand Up @@ -285,6 +289,9 @@ class HGraph : public InnerIndexInterface {
void
deserialize_label_info(StreamReader& reader) const;

void
deserialize(StreamReader& reader, bool force_v0_14);

// used in version [0.12.*, 0.14.*]
void
serialize_basic_info_v0_14(StreamWriter& writer) const;
Expand Down
18 changes: 18 additions & 0 deletions src/algorithm/hgraph_parameter.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@

#include "hgraph_parameter.h"

#include <nlohmann/json.hpp>

#include "datacell/extra_info_datacell_parameter.h"
#include "datacell/flatten_datacell_parameter.h"
#include "datacell/graph_datacell_parameter.h"
Expand Down Expand Up @@ -223,6 +225,22 @@ HGraphSearchParameters::FromJson(const std::string& json_string) {
if (params[INDEX_TYPE_HGRAPH].Contains("min_distance")) {
obj.min_distance = params[INDEX_TYPE_HGRAPH]["min_distance"].GetFloat();
}
if (params[INDEX_TYPE_HGRAPH].Contains(HGRAPH_PARAMETER_MAX_DUPLICATES_PER_GROUP)) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The JsonWrapper class already provides IsNumberUnsigned() and GetUint64() methods (see src/json_wrapper.h). The existing ef_search parsing in this same function (lines 335-336) already uses the correct pattern:

if (ef_search_json.IsNumberUnsigned()) {
    CHECK_ARGUMENT(ef_search_json.GetUint64() <= ...);
}

Including <nlohmann/json.hpp> and using GetInnerJson() to call is_number_unsigned() / get<uint64_t>() directly on the underlying nlohmann::json pointer is inconsistent with the codebase pattern. The JsonWrapper abstraction exists precisely to avoid direct nlohmann::json usage in parameter parsing code.

Suggested fix: remove the #include <nlohmann/json.hpp> and use max_duplicates.IsNumberUnsigned() / max_duplicates.GetUint64() instead.

const auto& max_duplicates =
params[INDEX_TYPE_HGRAPH][HGRAPH_PARAMETER_MAX_DUPLICATES_PER_GROUP];
CHECK_ARGUMENT(max_duplicates.IsNumberInteger(),
"max_duplicates_per_group must be an integer");
const auto* max_duplicates_json = max_duplicates.GetInnerJson();
if (max_duplicates_json->is_number_unsigned()) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[note] The unsigned overflow check via GetInnerJson()->is_number_unsigned() is a pragmatic approach to detect values that exceed int64_t range. However, this relies on nlohmann's internal number representation which could change between library versions. A more robust approach would be to parse the raw JSON string value and validate it with std::from_chars or similar. This is a minor portability concern — the current approach works correctly with the nlohmann versions used by this project.

CHECK_ARGUMENT(max_duplicates_json->get<uint64_t>() <=
static_cast<uint64_t>(std::numeric_limits<int64_t>::max()),
"max_duplicates_per_group exceeds int64_t range");
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[note] Direct use of nlohmann::json internals via GetInnerJson() bypasses the project's JsonType wrapper abstraction. The #include <nlohmann/json.hpp> is added solely for is_number_unsigned() and get<uint64_t>(). Consider adding IsUnsignedInteger() / GetUint64() to JsonType to keep the JSON library dependency encapsulated, or use GetInt() with a separate range check for negative values.

obj.max_duplicates_per_group = max_duplicates.GetInt();
CHECK_ARGUMENT(obj.max_duplicates_per_group >= -1,
fmt::format("max_duplicates_per_group({}) must be >= -1",
obj.max_duplicates_per_group));
}

return obj;
}
Expand Down
3 changes: 3 additions & 0 deletions src/algorithm/hgraph_parameter.h
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,9 @@ class HGraphSearchParameters : public IndexSearchParameter {
bool use_reorder{false};
bool use_extra_info_filter{false};
float min_distance{std::numeric_limits<float>::lowest()};
// Maximum additional duplicate IDs returned per group during KNN graph search.
// -1 means unlimited; 0 disables duplicate expansion.
int64_t max_duplicates_per_group{-1};

private:
HGraphSearchParameters() = default;
Expand Down
18 changes: 18 additions & 0 deletions src/algorithm/hgraph_parameter_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,24 @@ TEST_CASE("HGraph maps support_duplicate to graph parameter", "[ut][HGraphParame
REQUIRE(typed_param->duplicate_distance_threshold == 0.25F);
}

TEST_CASE("HGraph Search Parameters validate max_duplicates_per_group",
"[ut][HGraphSearchParameters][duplicate]") {
REQUIRE(vsag::HGraphSearchParameters::FromJson(R"({"hgraph": {"ef_search": 32}})")
.max_duplicates_per_group == -1);
REQUIRE(vsag::HGraphSearchParameters::FromJson(
R"({"hgraph": {"ef_search": 32, "max_duplicates_per_group": 0}})")
.max_duplicates_per_group == 0);
REQUIRE(vsag::HGraphSearchParameters::FromJson(
R"({"hgraph": {"ef_search": 32, "max_duplicates_per_group": 2}})")
.max_duplicates_per_group == 2);
REQUIRE_THROWS(vsag::HGraphSearchParameters::FromJson(
R"({"hgraph": {"ef_search": 32, "max_duplicates_per_group": -2}})"));
REQUIRE_THROWS(vsag::HGraphSearchParameters::FromJson(
R"({"hgraph": {"ef_search": 32, "max_duplicates_per_group": 1.5}})"));
REQUIRE_THROWS(vsag::HGraphSearchParameters::FromJson(
R"({"hgraph": {"ef_search": 32, "max_duplicates_per_group": 9223372036854775808}})"));
}

TEST_CASE("HGraph maps label_remap_type to inner index parameter", "[ut][HGraphParameter]") {
auto param = vsag::JsonType::Parse(R"({
"base_quantization_type": "fp32",
Expand Down
3 changes: 3 additions & 0 deletions src/analyzer/hgraph_analyzer.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -508,6 +508,9 @@ HGraphAnalyzer::GetDegreeDistribution() {
Vector<uint32_t> in_degree(this->total_count_, allocator_);
Vector<uint32_t> out_degree(this->total_count_, allocator_);
for (InnerIdType i = 0; i < this->total_count_; ++i) {
if (is_duplicate_ids_[i]) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[note] This is a straightforward fix — duplicate IDs have no real graph edges, so including them in the degree distribution would produce misleading zero-degree entries. The same is_duplicate_ids_ guard is already used consistently at lines 94 and 527 in this file. Good defensive consistency.

continue;
}
Vector<InnerIdType> neighbors(allocator_);
hgraph_->bottom_graph_->GetNeighbors(i, neighbors);
out_degree[i] = neighbors.size();
Expand Down
1 change: 1 addition & 0 deletions src/constants.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ const char* const HGRAPH_PRECISE_IO_TYPE = "precise_io_type";
const char* const HGRAPH_PRECISE_FILE_PATH = "precise_file_path";
const char* const HGRAPH_PARAMETER_EF_RUNTIME = "ef_search";
const char* const HGRAPH_PARAMETER_HOPS_LIMIT = "hops_limit";
const char* const HGRAPH_PARAMETER_MAX_DUPLICATES_PER_GROUP = "max_duplicates_per_group";
const char* const HGRAPH_EXTRA_INFO_SIZE = "extra_info_size";
const char* const HGRAPH_SUPPORT_DUPLICATE = "support_duplicate";
const char* const HGRAPH_DUPLICATE_DISTANCE_THRESHOLD = "duplicate_distance_threshold";
Expand Down
3 changes: 3 additions & 0 deletions src/impl/inner_search_param.h
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ class InnerSearchParam {

// use in search process with duplicate ids
bool consider_duplicate{false};
int64_t max_duplicates_per_group{-1};

// skip results with dist <= min_distance (for search iterator)
float min_distance{std::numeric_limits<float>::lowest()};
Expand All @@ -87,6 +88,8 @@ class InnerSearchParam {
factor = other.factor;
first_order_scan_ratio = other.first_order_scan_ratio;
parallel_search_thread_count = other.parallel_search_thread_count;
consider_duplicate = other.consider_duplicate;
max_duplicates_per_group = other.max_duplicates_per_group;
}
return *this;
}
Expand Down
Loading
Loading