From e8d0564cadfc664153b6020f9de03d8808217e44 Mon Sep 17 00:00:00 2001 From: Sanhaoji2 Date: Wed, 15 Jul 2026 20:40:44 +0800 Subject: [PATCH 01/25] Add live per-node filtered search + multiple-filter test report Adds a production-style, query-time filter-match path for multihop search and the benchmark/report artifacts from the multiple-filter evaluation. diskann-label-filter: - New live_filter module: InlineAttributeIndex (builder) -> FrozenAttributeIndex exposing a QueryLabelProvider whose is_match evaluates an AND/OR (+equality) predicate live, per visited node, against the node's roaring attribute-id set (lock-free read, integer-encoded terminals, no FFI, no global posting list). NOT/relational operators are rejected; evaluation errors are treated as non-match. Includes unit tests. diskann-benchmark: - New search type 'topk-multihop-live-filter' (plugin + SearchPhase wiring) that builds the attribute index once from the label file and evaluates the filter live during search, so reported latency includes the real per-node filter cost. - filters.rs helpers: build_inline_attribute_index / make_live_providers. diskann-benchmark-core / diskann-benchmark-runner: - Add p99.9 latency percentile to the search output (percentiles, knn Summary, result DTO + console table). Adds diskann-benchmark/multiple_filter_test_report.md documenting the 9-case multihop vs beta vs live-filter comparison (recall + latency incl. p90/p99/p99.9). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/search/graph/knn.rs | 17 +- .../src/utils/percentiles.rs | 9 + .../multiple_filter_test_report.md | 217 +++++++++++++++ .../src/backend/index/benchmarks.rs | 57 +++- diskann-benchmark/src/backend/index/result.rs | 29 +- .../src/backend/index/search/plugins.rs | 12 + diskann-benchmark/src/inputs/graph_index.rs | 17 ++ diskann-benchmark/src/utils/filters.rs | 53 ++++ diskann-label-filter/src/lib.rs | 2 + diskann-label-filter/src/live_filter.rs | 253 ++++++++++++++++++ 10 files changed, 661 insertions(+), 5 deletions(-) create mode 100644 diskann-benchmark/multiple_filter_test_report.md create mode 100644 diskann-label-filter/src/live_filter.rs diff --git a/diskann-benchmark-core/src/search/graph/knn.rs b/diskann-benchmark-core/src/search/graph/knn.rs index c46217b416..9eca27390f 100644 --- a/diskann-benchmark-core/src/search/graph/knn.rs +++ b/diskann-benchmark-core/src/search/graph/knn.rs @@ -164,6 +164,11 @@ pub struct Summary { /// This contains one entry per repetition in the batch. pub p99_latencies: Vec, + /// The 99.9th percentile latency for individual queries. + /// + /// This contains one entry per repetition in the batch. + pub p999_latencies: Vec, + /// The recall metrics for search. /// /// This implementation assumes that search is deterministic and only @@ -239,20 +244,29 @@ where let mut mean_latencies = Vec::with_capacity(results.len()); let mut p90_latencies = Vec::with_capacity(results.len()); let mut p99_latencies = Vec::with_capacity(results.len()); + let mut p999_latencies = Vec::with_capacity(results.len()); results.iter_mut().for_each(|r| { match percentiles::compute_percentiles(r.latencies_mut()) { Ok(values) => { - let percentiles::Percentiles { mean, p90, p99, .. } = values; + let percentiles::Percentiles { + mean, + p90, + p99, + p999, + .. + } = values; mean_latencies.push(mean); p90_latencies.push(p90); p99_latencies.push(p99); + p999_latencies.push(p999); } Err(_) => { let zero = MicroSeconds::new(0); mean_latencies.push(0.0); p90_latencies.push(zero); p99_latencies.push(zero); + p999_latencies.push(zero); } } }); @@ -265,6 +279,7 @@ where mean_latencies, p90_latencies, p99_latencies, + p999_latencies, mean_cmps: utils::average_all( results .iter() diff --git a/diskann-benchmark-runner/src/utils/percentiles.rs b/diskann-benchmark-runner/src/utils/percentiles.rs index 7b519e5f28..5f85571dc6 100644 --- a/diskann-benchmark-runner/src/utils/percentiles.rs +++ b/diskann-benchmark-runner/src/utils/percentiles.rs @@ -18,6 +18,7 @@ pub struct Percentiles { pub median: f64, pub p90: T, pub p99: T, + pub p999: T, } pub trait AsF64Lossy: Copy { @@ -77,6 +78,7 @@ where let p90 = x[((9 * len) / 10).min(len - 1)]; let p99 = x[((99 * len) / 100).min(len - 1)]; + let p999 = x[((999 * len) / 1000).min(len - 1)]; Ok(Percentiles { minimum: x[0], @@ -84,6 +86,7 @@ where median, p90, p99, + p999, }) } @@ -163,6 +166,7 @@ mod tests { median: 10.0, p90: 10, p99: 10, + p999: 10, }; assert_eq!(p, e); } @@ -177,6 +181,7 @@ mod tests { median: 1.5, p90: 2, p99: 2, + p999: 2, }; assert_eq!(p, e); } @@ -191,6 +196,7 @@ mod tests { median: 2.0, p90: 3, p99: 3, + p999: 3, }; assert_eq!(p, e); } @@ -205,6 +211,7 @@ mod tests { median: 5.0, p90: 9, p99: 9, + p999: 9, }; assert_eq!(p, e); } @@ -219,6 +226,7 @@ mod tests { median: 5.5, p90: 10, p99: 10, + p999: 10, }; assert_eq!(p, e); } @@ -233,6 +241,7 @@ mod tests { median: 6.0, p90: 10, p99: 11, + p999: 11, }; assert_eq!(p, e); } diff --git a/diskann-benchmark/multiple_filter_test_report.md b/diskann-benchmark/multiple_filter_test_report.md new file mode 100644 index 0000000000..0d4d15f46b --- /dev/null +++ b/diskann-benchmark/multiple_filter_test_report.md @@ -0,0 +1,217 @@ +# Multiple-Filter Search Test Report (corrected: set-membership labels) + +DiskANN filtered ANN search: **multihop** (hard filter) vs **beta** (soft filter), over 9 metadata-filter predicates, plus a **live per-node filter** variant that measures the real query-time filter cost (section 8). Labels use a **set-membership** model (each token is a boolean membership label), which correctly handles **multi-valued** attributes. + +_Generated: 2026-07-15 19:42_ + +## 0. Correction note (vs earlier version) + +The source labels are positional (`GeoLocationID_a, GeoLocationID_b, EN-US, ...`) and **geo is multi-valued**: 27.9% of the 9,996,160 lines carry more than one GeoLocationID (up to 49), and token0 is always *a* GeoLocationID but not the only one. + +The earlier encoding modeled only **token0** as a single-valued string field `geo`, so a predicate like `geo = GeoLocationID_9` matched only docs where 9 was the **first** token (1,357 docs), missing docs tagged with 9 elsewhere. The corrected model treats **every token as a boolean membership label**, so `contains GeoLocationID_9` matches all 363,817 docs. Effects are confined to the geo cases (S5-S8); market cases (S1-S4, S9) were already boolean and are unchanged. + +| Case | old "token0-geo" selectivity | corrected "contains" selectivity | +|---|---:|---:| +| S5 GeoLocationID_190 | 55.631% | 60.1133% | +| S6 GeoLocationID_90 | 11.303% | 12.5768% | +| S7 GeoLocationID_9 | 0.014% | 3.6396% | +| S8 GeoLocationID_119 | 0.790% | 1.1682% | + +Recall impact of the correction (multihop, recall@150): +- **S7 (geo 9): 0.49 -> 0.88 @L150, 0.77 -> 0.97 @L1000.** The earlier "collapse" was an encoding artifact (ultra-sparse 1,357-doc set); the true "tagged-with-9" set (363k) is dense and reachable. +- S5/S6/S8 grew slightly in selectivity with little recall change (already broad enough). + +## 1. Environment & dataset + +| | | +|---|---| +| Host | Windows, 16 logical cores | +| Points | 9,996,160 | +| Dimensions | 64 (int8 source, converted to float32 for build) | +| Queries | first 1,000 embeddings | +| Distinct labels | 596 (548 GeoLocationIDs + 48 market codes) | + +## 2. Index build (once, reused) + +float32, distance squared_l2, max_degree 64, l_build 100, alpha 1.2, medoid start, 16 build threads; build ~400 s; saved as `idxsave_full` and loaded for every search job. + +## 3. Methodology + +- **Labels (set-membership):** each line token -> boolean field `{"doc_id":i,"GeoLocationID_190":true,"EN-US":true,...}`. A label matches regardless of position. Predicates use `{"