-
Notifications
You must be signed in to change notification settings - Fork 3.9k
[opt](Ann) Cancel index building if input rows is less than the min_train_rows #60358
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
499284a
e3c93b0
9244d95
ed3072f
b609cef
817b9b7
b27cd09
afebd66
c5f1c83
895aae1
d6cd966
7d4eebc
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -74,6 +74,7 @@ Status AnnIndexReader::load_index(io::IOContext* io_ctx) { | |
| DorisMetrics::instance()->ann_index_load_cnt->increment(1); | ||
|
|
||
| try { | ||
| // An exception will be thrown if loading fails | ||
| RETURN_IF_ERROR( | ||
| _index_file_reader->init(config::inverted_index_read_buffer_size, io_ctx)); | ||
| Result<std::unique_ptr<DorisCompoundReader, DirectoryDeleter>> compound_dir; | ||
|
|
@@ -87,23 +88,30 @@ Status AnnIndexReader::load_index(io::IOContext* io_ctx) { | |
| _vector_index->set_type(_index_type); | ||
| RETURN_IF_ERROR(_vector_index->load(compound_dir->get())); | ||
| } catch (CLuceneError& err) { | ||
| LOG_ERROR("Failed to load ann index: {}", err.what()); | ||
| return Status::Error<ErrorCode::INVERTED_INDEX_CLUCENE_ERROR>( | ||
| "CLuceneError occur when open ann idx file, error msg: {}", err.what()); | ||
| } | ||
| return Status::OK(); | ||
| }); | ||
| } | ||
|
|
||
| Status AnnIndexReader::query(io::IOContext* io_ctx, AnnTopNParam* param, AnnIndexStats* stats) { | ||
| bool AnnIndexReader::try_load_index(io::IOContext* io_ctx) { | ||
| #ifndef BE_TEST | ||
| { | ||
| SCOPED_TIMER(&(stats->load_index_costs_ns)); | ||
| RETURN_IF_ERROR(load_index(io_ctx)); | ||
| double load_costs_ms = static_cast<double>(stats->load_index_costs_ns.value()) / 1000.0; | ||
| DorisMetrics::instance()->ann_index_load_costs_ms->increment( | ||
| static_cast<int64_t>(load_costs_ms)); | ||
| Status st = load_index(io_ctx); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Lost metrics tracking. The original code had: SCOPED_TIMER(&(stats->load_index_costs_ns));
RETURN_IF_ERROR(load_index(io_ctx));
double load_costs_ms = ...
DorisMetrics::instance()->ann_index_load_costs_ms->increment(...);By moving load to
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @zhiqiang-hhhh need check this
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| if (!st.ok()) { | ||
| LOG_WARNING("Failed to load ann index, will fallback to brute force search: {}", | ||
| st.to_string()); | ||
| return false; | ||
| } | ||
| #endif | ||
| return true; | ||
| } | ||
|
|
||
| Status AnnIndexReader::query(io::IOContext* io_ctx, AnnTopNParam* param, AnnIndexStats* stats) { | ||
| // Index should be loaded before calling query | ||
| DCHECK(_vector_index != nullptr); | ||
|
|
||
| { | ||
| DorisMetrics::instance()->ann_index_search_cnt->increment(1); | ||
| SCOPED_TIMER(&(stats->search_costs_ns)); | ||
|
|
@@ -162,16 +170,10 @@ Status AnnIndexReader::range_search(const AnnRangeSearchParams& params, | |
| const VectorSearchUserParams& custom_params, | ||
| segment_v2::AnnRangeSearchResult* result, | ||
| segment_v2::AnnIndexStats* stats, io::IOContext* io_ctx) { | ||
| // Index should be loaded before calling range_search | ||
| DCHECK(_vector_index != nullptr); | ||
|
|
||
| DCHECK(stats != nullptr); | ||
| #ifndef BE_TEST | ||
| { | ||
| SCOPED_TIMER(&(stats->load_index_costs_ns)); | ||
| RETURN_IF_ERROR(load_index(io_ctx)); | ||
| double load_costs_ms = static_cast<double>(stats->load_index_costs_ns.value()) / 1000.0; | ||
| DorisMetrics::instance()->ann_index_load_costs_ms->increment( | ||
| static_cast<int64_t>(load_costs_ms)); | ||
| } | ||
| #endif | ||
| { | ||
| DorisMetrics::instance()->ann_index_search_cnt->increment(1); | ||
| SCOPED_TIMER(&(stats->search_costs_ns)); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -127,6 +127,7 @@ Status AnnIndexColumnWriter::add_array_values(size_t field_size, const void* val | |
| RETURN_IF_ERROR( | ||
| _vector_index->add(AnnIndexColumnWriter::chunk_size(), _float_array.data())); | ||
| _float_array.clear(); | ||
| _need_save_index = true; | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -151,16 +152,55 @@ int64_t AnnIndexColumnWriter::size() const { | |
| } | ||
|
|
||
| Status AnnIndexColumnWriter::finish() { | ||
| Int64 min_train_rows = _vector_index->get_min_train_rows(); | ||
|
|
||
| // Check if we have enough rows to train the index | ||
| // train/add the remaining data | ||
| if (!_float_array.empty()) { | ||
| if (_float_array.empty()) { | ||
| if (_need_save_index) { | ||
| return _vector_index->save(_dir.get()); | ||
| } else { | ||
| // No data was added at all. This can happen if the segment has 0 rows | ||
| // or all rows were filtered out. We need to delete the directory entry | ||
| // to avoid writing an empty/invalid index file. | ||
| LOG_INFO("No data to train/add for ANN index. Skipping index building."); | ||
| return _index_file_writer->delete_index(_index_meta); | ||
| } | ||
| } else { | ||
| DCHECK(_float_array.size() % _vector_index->get_dimension() == 0); | ||
|
|
||
| Int64 num_rows = _float_array.size() / _vector_index->get_dimension(); | ||
| RETURN_IF_ERROR(_vector_index->train(num_rows, _float_array.data())); | ||
| RETURN_IF_ERROR(_vector_index->add(num_rows, _float_array.data())); | ||
| _float_array.clear(); | ||
| } | ||
|
|
||
| return _vector_index->save(_dir.get()); | ||
| if (num_rows >= min_train_rows) { | ||
| RETURN_IF_ERROR(_vector_index->train(num_rows, _float_array.data())); | ||
| RETURN_IF_ERROR(_vector_index->add(num_rows, _float_array.data())); | ||
| _float_array.clear(); | ||
| return _vector_index->save(_dir.get()); | ||
| } else { | ||
| // It happens to have not enough data to train. | ||
| // If we have data to add before, we still need to save the index. | ||
| if (_need_save_index) { | ||
| // For IVF indexes, adding remaining vectors without training is acceptable | ||
| // because the quantizer was already trained on previous batches. These vectors | ||
| // are simply added to the nearest clusters without retraining. | ||
| RETURN_IF_ERROR(_vector_index->add(num_rows, _float_array.data())); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Potential correctness concern: When
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @zhiqiang-hhhh need check this
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
A comment has been added. |
||
| _float_array.clear(); | ||
| return _vector_index->save(_dir.get()); | ||
| } else { | ||
| // Not enough data to train and no data added before. | ||
| // Means this is a very small segment, we can skip the index building. | ||
| // We need to delete the directory entry from index_file_writer to avoid | ||
| // writing an empty/invalid index file which causes "IndexInput read past EOF" error. | ||
| LOG_INFO( | ||
| "Remaining data size {} is less than minimum {} rows required for ANN " | ||
| "index " | ||
| "training. Skipping index building for this segment.", | ||
| num_rows, min_train_rows); | ||
| _float_array.clear(); | ||
| return _index_file_writer->delete_index(_index_meta); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| #include "common/compile_check_end.h" | ||
| } // namespace doris::segment_v2 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -289,6 +289,32 @@ doris::Status FaissVectorIndex::add(Int64 n, const float* vec) { | |
| return doris::Status::OK(); | ||
| } | ||
|
|
||
| Int64 FaissVectorIndex::get_min_train_rows() const { | ||
| // For IVF indexes, the minimum number of training points should be at least | ||
| // equal to the number of clusters (nlist). FAISS requires this for k-means clustering. | ||
| Int64 ivf_min = 0; | ||
| if (_params.index_type == FaissBuildParameter::IndexType::IVF) { | ||
| ivf_min = _params.ivf_nlist; | ||
| } | ||
|
|
||
| // Calculate minimum training rows required by the quantizer | ||
| Int64 quantizer_min = 0; | ||
| if (_params.quantizer == FaissBuildParameter::Quantizer::PQ) { | ||
| // For PQ, FAISS uses ksub = 2^pq_nbits and recommends ksub * 100 training vectors. | ||
| // This threshold depends on pq_nbits only (independent of pq_m). | ||
| // See code from contrib/faiss/faiss/impl/ProductQuantizer.cpp::65 | ||
| quantizer_min = (1LL << _params.pq_nbits) * 100; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1 — PQ The actual formula here is
However, multiple test comments reference completely different formulas:
Unit tests use mocked Suggestion: Fix all comments to match this formula, and use boundary-closer data sizes in regression tests. |
||
| } else if (_params.quantizer == FaissBuildParameter::Quantizer::SQ4 || | ||
| _params.quantizer == FaissBuildParameter::Quantizer::SQ8) { | ||
| // For SQ, minimal training requirement as scalar quantization is simpler | ||
| quantizer_min = 1; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Comment/code mismatch. Comment says "use a minimum of 20 training vectors, similar to IVF's nlist * 2 with nlist=10" but the code sets |
||
| } | ||
| // For FLAT, no minimum training data required | ||
|
|
||
| // Return the maximum of IVF and quantizer requirements | ||
| return std::max(ivf_min, quantizer_min); | ||
| } | ||
|
|
||
| void FaissVectorIndex::build(const FaissBuildParameter& params) { | ||
| _params = params; | ||
| _dimension = params.dim; | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2 — Range search fallback missing
ann_fall_back_brute_force_cntincrementWhen
try_load_index()returns false here, the code returnsStatus::OK()without incrementing any fallback counter. The TopN path insegment_iterator.cppconsistently incrementsann_fall_back_brute_force_cntat every fallback branch (7 places). This observability gap makes it harder to diagnose range-search fallback scenarios in query profiles.Consider adding a stats counter increment here as well.