Skip to content

Add ACORN-1 search#7414

Merged
xzfc merged 8 commits intodevfrom
acorn
Oct 24, 2025
Merged

Add ACORN-1 search#7414
xzfc merged 8 commits intodevfrom
acorn

Conversation

@xzfc
Copy link
Member

@xzfc xzfc commented Oct 16, 2025

This PR adds support for ACORN-1 as described in the ACORN paper.
This is a variation of HNSW search that "hops" over neighbors that do not pass the filter.
The index structure remains the same.

The new search is controlled/enabled via a new search parameter acorn_limit_factor, e.g.:

curl -X POST 'http://localhost:6333/collections/benchmark/points/search' -d '
    {
        "vector": […],
        "filter": {…},
        "top": 10,
        "params": {
            "acorn_limit_factor": 10.0
        }
    }'

When the new parameter is null or not present, the search behaves the same as before (without ACORN-1).

The design/name of a new parameter is subject to change later as we perform more experiments (we might add more knobs or change it to a boolean flag).


Also, since the number of "search on level" functions reached five, this PR adds a module doc comment describing their differences.

Benchmarks

Benchmark results on random vectors:

  • dim=128, m=16, payload_m=0, ef_search=256, num_points=500_000
  • randomly generated vectors (from -1.0 to 1.0)
  • randomly generated payloads (two fields, each has 5 possible values)
  • search filters match both of the fields (i.e. cardinality is 1/25)

Without ACORN:

  • Mean accuracy 0.006
  • Mean time 7.5 ms (132.8 RPS)

With ACORN:

  • Mean accuracy 0.631
  • Mean time 30.2 ms (33.2 RPS)

@xzfc xzfc requested review from generall and timvisee October 16, 2025 18:16
coderabbitai[bot]

This comment was marked as resolved.

coderabbitai[bot]

This comment was marked as resolved.

Comment on lines +188 to +190
if points_ids.len() < limit {
points_ids.push(link);
}
Copy link
Member

Choose a reason for hiding this comment

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

Does a try_for_each_link make sense here as well? To shortcut when we've filled the points ID list?

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, your suggestion makes sense for consistency.
(either both limit and try_for_each_link; or neither)

  • note to self

let mut search_context = SearchContext::new(ef);
search_context.process_candidate(level_entry);

let limit = (self.get_m(level) as f64 * acorn_limit_factor).ceil() as usize;
Copy link
Member

Choose a reason for hiding this comment

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

Shouldn't we only use this limit for the 2nd hop? Not for the initial search on each node?

Copy link
Member

Choose a reason for hiding this comment

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

+1

coderabbitai[bot]

This comment was marked as resolved.

coderabbitai[bot]

This comment was marked as resolved.

@qdrant qdrant deleted a comment from coderabbitai bot Oct 23, 2025
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Oct 24, 2025

📝 Walkthrough

Walkthrough

Adds ACORN search support and wiring across the stack: new AcornSearchParams proto and OpenAPI schema; SearchParams gains optional acorn field; gRPC ↔ internal conversions updated to carry ACORN params; new AcornSearchParams and constant in segment types; SearchAlgorithm enum (Hnsw, Acorn) added and propagated into GraphLayers::search signature; new try_for_each_link API and search_on_level_acorn surface added to graph layers and builder; HNSW search flow updated to compute/select algorithm based on selectivity and forward the chosen algorithm; benchmarks, tests, python wrapper, and validation rules updated accordingly.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Changes are cross-cutting: proto/OpenAPI/docs, gRPC conversions, public types, core HNSW search routing, new trait methods, and tests/benches. Many files follow repetitive patterns, but critical logic in graph_layers/hnsw and new algorithm dispatch requires careful review.

Possibly related PRs

Suggested reviewers

  • timvisee
  • generall
  • IvanPleshkov

Pre-merge checks and finishing touches

✅ Passed checks (3 passed)
Check name Status Explanation
Title Check ✅ Passed The title "Add ACORN-1 search" is fully related to the main change in the changeset. The pull request fundamentally adds support for ACORN-1, a new search algorithm variant of HNSW. The raw summary confirms this through multiple file changes including new SearchAlgorithm enum with Acorn variant, AcornSearchParams structures, search_on_level_acorn implementation, and algorithm dispatch logic in graph layers. The title is concise, clear, and specific enough for a teammate scanning history to understand that this PR introduces ACORN-1 search functionality.
Description Check ✅ Passed The description is clearly related to the changeset and provides meaningful context about the PR's purpose. It explains that the PR adds ACORN-1 search support as described in the referenced paper, how it works (hops over neighbors failing filters), how it's controlled (via search parameters), and includes concrete benchmark results demonstrating the impact. While there is a minor discrepancy between the description's mention of "acorn_limit_factor" and the actual code implementation using AcornSearchParams with enable and max_selectivity fields, the description acknowledges that parameter design is subject to change. The description also mentions the addition of module documentation for search functions, which aligns with the changeset.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch acorn

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 5

♻️ Duplicate comments (1)
lib/segment/src/index/hnsw_index/graph_layers.rs (1)

150-244: Don’t short‑circuit the 1‑hop scan; it drops potential 2‑hop exploration.

Breaking the 1‑hop iteration once hop1_limit scored items are gathered prevents collecting remaining non‑passing neighbors for 2‑hop “hop‑over,” reducing recall. Limit should apply only to 2‑hop per 1‑hop neighbor, not the initial 1‑hop scan. This was previously raised.

Apply this change: keep scanning all 1‑hop links, but cap per‑hop2 expansion only.

-            // Collect 1-hop neighbors (direct neighbors)
-            _ = self.try_for_each_link(candidate.idx, level, |hop1| {
-                if hop1_visited_list.check_and_update_visited(hop1) {
-                    return ControlFlow::Continue(());
-                }
-
-                if points_scorer.filters().check_vector(hop1) {
-                    to_score.push(hop1);
-                    if to_score.len() >= hop1_limit {
-                        return ControlFlow::Break(());
-                    }
-                } else {
-                    to_explore.push(hop1);
-                }
-                ControlFlow::Continue(())
-            });
+            // Collect 1-hop neighbors (direct neighbors): don't early-break.
+            let filters = points_scorer.filters();
+            self.for_each_link(candidate.idx, level, |hop1| {
+                if hop1_visited_list.check_and_update_visited(hop1) {
+                    return;
+                }
+                if filters.check_vector(hop1) {
+                    to_score.push(hop1);
+                } else {
+                    to_explore.push(hop1);
+                }
+            });

Optional: if you still want to bound 1‑hop scoring cost, gate only the push, not the iteration:

if filters.check_vector(hop1) && to_score.len() < hop1_limit { to_score.push(hop1); }
🧹 Nitpick comments (8)
lib/segment/src/index/hnsw_index/point_scorer.rs (1)

226-239: Add documentation for the new public method.

The implementation is correct and provides the unfiltered scoring capability needed for ACORN. However, as a public method, it should include a doc comment explaining its purpose, parameters, and return value—especially noting that it bypasses all filters.

Apply this diff to add documentation:

+    /// Scores the given slice of point IDs without applying filters.
+    ///
+    /// Unlike [`score_points`], this method does not check whether points satisfy
+    /// the filter context or are marked as deleted. The caller is responsible for
+    /// ensuring the points are valid.
+    ///
+    /// # Arguments
+    ///
+    /// * `point_ids` - Slice of point IDs to score.
+    ///
+    /// # Returns
+    ///
+    /// An iterator of scored point offsets.
     pub fn score_points_unfiltered(
         &mut self,
         point_ids: &[PointOffsetType],
     ) -> impl Iterator<Item = ScoredPointOffset> {
docs/redoc/master/openapi.json (1)

9172-9184: Document the max_selectivity default in the schema

The description calls out a default of 0.4, but the JSON schema doesn’t expose it. Adding the default attribute keeps ReDoc/SDK consumers in sync with the implementation and avoids clients needing to hard-code the value themselves.

           "max_selectivity": {
             "description": "Maximum selectivity of filters to enable ACORN.\n\nIf estimated filters selectivity is higher than this value, ACORN will not be used. Selectivity is estimated as: `estimated number of points satisfying the filters / total number of points`.\n\n0.0 for never, 1.0 for always. Default is 0.4.",
             "type": "number",
             "format": "double",
             "maximum": 1,
             "minimum": 0,
-            "nullable": true
+            "nullable": true,
+            "default": 0.4
           }
lib/segment/src/types.rs (1)

531-556: Default semantics vs. docs mismatch for max_selectivity

Docs say “Default is 0.4”, but derive(Default) yields enable = false and max_selectivity = None. This can confuse users and serializers. Consider making the default explicit or updating the doc to “unspecified uses internal default 0.4”.

Two options:

  • Preferred (explicit default when Acorn is present):
-#[derive(
-    Debug, Deserialize, Serialize, JsonSchema, Validate, Clone, Copy, PartialEq, Default, Hash,
-)]
+#[derive(
+    Debug, Deserialize, Serialize, JsonSchema, Validate, Clone, Copy, PartialEq, Hash,
+)]
 pub struct AcornSearchParams {
@@
-    #[serde(default)]
-    #[serde(skip_serializing_if = "Option::is_none")]
-    #[validate(range(min = 0.0, max = 1.0))]
-    pub max_selectivity: Option<OrderedFloat<f64>>,
+    #[serde(default = "acorn_max_selectivity_default")]
+    #[validate(range(min = 0.0, max = 1.0))]
+    pub max_selectivity: OrderedFloat<f64>,
 }
+
+fn acorn_max_selectivity_default() -> OrderedFloat<f64> {
+    OrderedFloat(ACORN_MAX_SELECTIVITY_DEFAULT)
+}
+
+impl Default for AcornSearchParams {
+    fn default() -> Self {
+        Self { enable: false, max_selectivity: acorn_max_selectivity_default() }
+    }
+}
  • Or, keep Option but fix docs and use the constant when None at call sites.

Pick one and align GRPC/REST docs accordingly.

lib/segment/src/index/hnsw_index/graph_layers_builder.rs (1)

23-25: Redundant test-only import

SearchAlgorithm is imported again inside the tests module below. You can drop this top-level #[cfg(test)] import to avoid duplication.

lib/segment/src/index/hnsw_index/graph_layers.rs (3)

80-85: Provide a default for SearchAlgorithm (Hnsw) to reduce call‑site churn.

Add Default to ease migration and allow API wrappers to use ..Default::default() patterns.

 #[derive(Debug, Clone, Copy, Eq, PartialEq)]
 pub enum SearchAlgorithm {
     Hnsw,
     Acorn,
 }
+
+impl Default for SearchAlgorithm {
+    fn default() -> Self {
+        SearchAlgorithm::Hnsw
+    }
+}

184-186: Consider extracting the “16” into a named constant.

Magic number; make the reservation heuristic self‑documenting.

-        let mut to_score = Vec::with_capacity(hop1_limit * hop2_limit.min(16));
-        let mut to_explore = Vec::with_capacity(hop1_limit * hop2_limit.min(16));
+        const ACORN_RESERVE_HOP2_MAX: usize = 16;
+        let reserve = hop1_limit * hop2_limit.min(ACORN_RESERVE_HOP2_MAX);
+        let mut to_score = Vec::with_capacity(reserve);
+        let mut to_explore = Vec::with_capacity(reserve);

741-749: Tests: update done; add ACORN path coverage.

Add a focused test that:

  • constructs a small graph where 1‑hop neighbors fail the filter and 2‑hop pass;
  • asserts HNSW misses but ACORN returns expected points.

I can draft this test if helpful.

lib/segment/src/index/hnsw_index/hnsw.rs (1)

979-1005: ACORN algorithm selection logic looks sound.

  • Gated by enable, m0 != 0, and presence of filter.
  • Uses adjusted cardinality and available vectors to compute selectivity.
  • Falls back to HNSW when not under threshold.

Minor: cache filter.is_some() into a local to slightly simplify the condition.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 06e9b26 and 14bfb23.

📒 Files selected for processing (17)
  • docs/grpc/docs.md (3 hunks)
  • docs/redoc/master/openapi.json (2 hunks)
  • lib/api/build.rs (1 hunks)
  • lib/api/src/grpc/conversions.rs (4 hunks)
  • lib/api/src/grpc/proto/points.proto (2 hunks)
  • lib/api/src/grpc/qdrant.rs (2 hunks)
  • lib/edge/python/src/search.rs (1 hunks)
  • lib/segment/benches/hnsw_build_asymptotic.rs (3 hunks)
  • lib/segment/benches/hnsw_search_graph.rs (3 hunks)
  • lib/segment/src/index/hnsw_index/gpu/mod.rs (2 hunks)
  • lib/segment/src/index/hnsw_index/graph_layers.rs (10 hunks)
  • lib/segment/src/index/hnsw_index/graph_layers_builder.rs (5 hunks)
  • lib/segment/src/index/hnsw_index/hnsw.rs (5 hunks)
  • lib/segment/src/index/hnsw_index/point_scorer.rs (1 hunks)
  • lib/segment/src/index/hnsw_index/tests/test_compact_graph_layer.rs (2 hunks)
  • lib/segment/src/types.rs (2 hunks)
  • lib/segment/tests/integration/segment_tests.rs (1 hunks)
🧰 Additional context used
📓 Path-based instructions (3)
**/*.rs

📄 CodeRabbit inference engine (.github/review-rules.md)

**/*.rs: Prefer explicit SomeType::from(x) over implicit x.into() in Rust code
Do not use transmute_from_u8, transmute_to_u8, transmute_from_u8_to_slice, transmute_from_u8_to_mut_slice, transmute_to_u8_slice in new code; use bytemuck or zerocopy instead

Files:

  • lib/api/build.rs
  • lib/segment/benches/hnsw_search_graph.rs
  • lib/segment/benches/hnsw_build_asymptotic.rs
  • lib/segment/src/index/hnsw_index/point_scorer.rs
  • lib/segment/src/index/hnsw_index/graph_layers_builder.rs
  • lib/segment/src/index/hnsw_index/graph_layers.rs
  • lib/segment/tests/integration/segment_tests.rs
  • lib/segment/src/types.rs
  • lib/api/src/grpc/conversions.rs
  • lib/segment/src/index/hnsw_index/tests/test_compact_graph_layer.rs
  • lib/segment/src/index/hnsw_index/hnsw.rs
  • lib/api/src/grpc/qdrant.rs
  • lib/edge/python/src/search.rs
  • lib/segment/src/index/hnsw_index/gpu/mod.rs
**/{tests,benches}/**/*.rs

📄 CodeRabbit inference engine (.github/review-rules.md)

Using .unwrap() and panic!() in tests and benchmarks is acceptable and should not be flagged

Files:

  • lib/segment/benches/hnsw_search_graph.rs
  • lib/segment/benches/hnsw_build_asymptotic.rs
  • lib/segment/tests/integration/segment_tests.rs
  • lib/segment/src/index/hnsw_index/tests/test_compact_graph_layer.rs
**/src/**/*.rs

📄 CodeRabbit inference engine (.github/review-rules.md)

**/src/**/*.rs: Prefer exhaustive match arms over a catch-all _ arm to avoid missing new enum variants (except in tests/benchmarks or when provably safe)
Prefer explicit field ignoring with : _ over .. in struct patterns (except in tests/benchmarks or when provably safe)

Files:

  • lib/segment/src/index/hnsw_index/point_scorer.rs
  • lib/segment/src/index/hnsw_index/graph_layers_builder.rs
  • lib/segment/src/index/hnsw_index/graph_layers.rs
  • lib/segment/src/types.rs
  • lib/api/src/grpc/conversions.rs
  • lib/segment/src/index/hnsw_index/tests/test_compact_graph_layer.rs
  • lib/segment/src/index/hnsw_index/hnsw.rs
  • lib/api/src/grpc/qdrant.rs
  • lib/edge/python/src/search.rs
  • lib/segment/src/index/hnsw_index/gpu/mod.rs
🧠 Learnings (1)
📚 Learning: 2025-05-30T15:26:14.488Z
Learnt from: generall
PR: qdrant/qdrant#6479
File: lib/segment/src/index/hnsw_index/graph_layers_builder.rs:137-182
Timestamp: 2025-05-30T15:26:14.488Z
Learning: In the subgraph_connectivity method in graph_layers_builder.rs, the previous_visited_points vector and visited BitVec entry point marking are automatically re-initialized at the start of each retry loop iteration, so manual clearing is not needed.

Applied to files:

  • lib/segment/src/index/hnsw_index/graph_layers_builder.rs
🧬 Code graph analysis (8)
lib/segment/benches/hnsw_build_asymptotic.rs (1)
lib/segment/src/fixtures/index_fixtures.rs (1)
  • scorer (87-97)
lib/segment/src/index/hnsw_index/graph_layers_builder.rs (2)
lib/segment/src/index/hnsw_index/graph_layers.rs (2)
  • try_for_each_link (93-100)
  • try_for_each_link (466-476)
lib/segment/src/index/hnsw_index/graph_links/view.rs (1)
  • links (215-246)
lib/segment/src/index/hnsw_index/graph_layers.rs (2)
lib/segment/src/index/hnsw_index/graph_layers_builder.rs (2)
  • for_each_link (58-69)
  • try_for_each_link (71-88)
lib/segment/src/common/operation_error.rs (1)
  • check_process_stopped (248-253)
lib/segment/src/types.rs (1)
lib/api/src/grpc/validate.rs (4)
  • validate (14-14)
  • validate (19-21)
  • validate (29-31)
  • validate (39-41)
lib/api/src/grpc/conversions.rs (1)
lib/segment/src/types.rs (16)
  • from (140-142)
  • from (200-202)
  • from (256-261)
  • from (265-270)
  • from (889-891)
  • from (895-897)
  • from (901-903)
  • from (938-947)
  • from (951-954)
  • from (981-990)
  • from (1002-1005)
  • from (1242-1286)
  • from (1805-1807)
  • from (1811-1813)
  • from (1817-1819)
  • from (1832-1837)
lib/segment/src/index/hnsw_index/tests/test_compact_graph_layer.rs (1)
lib/segment/src/fixtures/index_fixtures.rs (1)
  • scorer (87-97)
lib/segment/src/index/hnsw_index/hnsw.rs (3)
lib/segment/src/vector_storage/vector_storage_base.rs (17)
  • v (660-660)
  • v (662-662)
  • v (664-664)
  • v (665-665)
  • v (667-667)
  • v (669-669)
  • v (670-670)
  • v (671-671)
  • v (672-672)
  • v (673-673)
  • v (674-674)
  • v (675-675)
  • v (676-676)
  • v (677-677)
  • v (678-678)
  • v (944-944)
  • available_vector_count (82-85)
lib/segment/src/index/query_estimator.rs (1)
  • adjust_to_available_vectors (25-65)
lib/segment/src/index/hnsw_index/graph_layers.rs (1)
  • search_with_vectors (564-596)
lib/api/src/grpc/qdrant.rs (1)
lib/api/src/grpc/validate.rs (8)
  • validate (14-14)
  • validate (19-21)
  • validate (29-31)
  • validate (39-41)
  • validate (49-54)
  • validate (58-64)
  • validate (68-74)
  • validate (78-85)
🪛 markdownlint-cli2 (0.18.1)
docs/grpc/docs.md

140-140: Unordered list indentation
Expected: 2; Actual: 4

(MD007, ul-indent)


2449-2449: Table pipe style
Expected: leading_and_trailing; Actual: leading_only; Missing trailing pipe

(MD055, table-pipe-style)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (11)
  • GitHub Check: test-consensus-compose
  • GitHub Check: rust-tests (windows-latest)
  • GitHub Check: rust-tests (ubuntu-latest)
  • GitHub Check: rust-tests (macos-latest)
  • GitHub Check: rust-tests-no-rocksdb (ubuntu-latest)
  • GitHub Check: test-consistency
  • GitHub Check: integration-tests
  • GitHub Check: test-shard-snapshot-api-s3-minio
  • GitHub Check: e2e-tests
  • GitHub Check: lint
  • GitHub Check: integration-tests-consensus
🔇 Additional comments (26)
lib/api/build.rs (1)

200-200: LGTM! Validation constraint is appropriate.

The range constraint [0.0, 1.0] for max_selectivity is correct for a selectivity parameter. The placement within the Points service validation is also appropriate.

lib/segment/src/index/hnsw_index/point_scorer.rs (1)

223-224: Clean refactoring that maintains existing behavior.

Delegating to score_points_unfiltered separates filtering concerns from scoring logic, which is beneficial for supporting ACORN's unfiltered scoring needs.

lib/segment/src/types.rs (1)

585-590: SearchParams.acorn field addition looks good

Optional nested params with serde default and nested validation is consistent with existing patterns. LGTM.

lib/segment/src/index/hnsw_index/tests/test_compact_graph_layer.rs (2)

10-10: Correct import for new enum

Bringing SearchAlgorithm alongside GraphLayersBase is required for the new signature. LGTM.


99-106: API migration to .search(..., SearchAlgorithm::Hnsw, ...)

Signature adaptation is correct and preserves previous behavior.

lib/segment/benches/hnsw_build_asymptotic.rs (3)

11-11: Bench imports updated appropriately

Importing SearchAlgorithm for the new search API is correct.


42-49: Bench uses Hnsw path explicitly

The added SearchAlgorithm::Hnsw in .search matches intent and keeps results comparable across runs.


68-75: Second bench call updated consistently

Consistent with prior change; no issues.

lib/segment/src/index/hnsw_index/graph_layers_builder.rs (3)

4-4: ControlFlow import

Needed for the new fallible iterator-style method. LGTM.


764-771: Tests: updated search signature

Passing SearchAlgorithm::Hnsw is correct here.


877-884: Tests: second search call updated consistently

Looks good.

lib/segment/src/index/hnsw_index/gpu/mod.rs (3)

106-106: Tests import new enum and type

Bringing in GraphLayers and SearchAlgorithm for test comparisons is correct.


217-224: GPU path: explicit Hnsw selection

API migration correct; preserves baseline for GPU-vs-CPU quality checks.


231-237: CPU ref path: consistent update

Mirrors GPU change; no issues.

lib/segment/src/index/hnsw_index/graph_layers.rs (4)

93-101: try_for_each_link API looks good.

Consistent with builder’s variant; enables short‑circuiting where needed.


466-477: GraphLayers.try_for_each_link delegation is correct.

Properly forwards to links container; aligns with builder’s implementation.


1-26: Docs/imports LGTM.

Helpful module docs; ControlFlow import and doc additions improve readability.

Also applies to: 29-29, 105-108, 276-279, 333-336, 417-420


530-561: Review comment is incorrect—GraphLayers::search is already properly updated.

The code is correct. GraphLayers::search is an internal method called only once in the codebase (line 1067 of hnsw.rs), and that call site already passes SearchAlgorithm as the 3rd argument with the correct signature. The verification script in the review caught false positives—the .search() calls found were from other index types (VectorIndex wrappers, SparseVectorIndex, etc.), not GraphLayers. There are no "outdated calls" to fix. The API change is properly encapsulated within the Hnsw implementation.

Likely an incorrect or invalid review comment.

lib/api/src/grpc/conversions.rs (4)

11-11: Import for OrderedFloat is appropriate.

Required for wrapping/unwrapping max_selectivity. No issues.


47-58: qdrant proto import extended (AcornSearchParams).

Looks consistent with added conversions below.


790-801: Bi‑directional AcornSearchParams conversions are correct and symmetric.

  • enable defaults to false when unset on gRPC side.
  • max_selectivity uses OrderedFloat wrapper safely both ways.

Consider adding a small round‑trip test (gRPC -> internal -> gRPC) for AcornSearchParams to guard serialization defaults.

Also applies to: 803-814


823-832: SearchParams now carries acorn across the boundary.

Mapping uses explicit Type::from, matches our guidelines. Defaulting of other fields preserved.

Please ensure REST conversions (if any) also propagate acorn consistently; if not applicable, ignore.

Also applies to: 842-851

lib/segment/src/index/hnsw_index/hnsw.rs (4)

46-48: Importing SearchAlgorithm is correct.

Needed for routing to HNSW vs ACORN.


66-68: ACORN constants and types import OK.

ACORN_MAX_SELECTIVITY_DEFAULT and SearchParams are used below as expected.


1008-1012: Early exit for with‑vectors path when using ACORN.

Clear behavior until ACORN-on-link-vectors is implemented.

Optionally add a TODO linking to an issue if ACORN with-vectors is planned, to avoid regressions later.


1069-1071: Pass-through of algorithm into graph.search is correct.

API change is plumbed properly.

Comment on lines +71 to +88
fn try_for_each_link<F>(
&self,
point_id: PointOffsetType,
level: usize,
mut f: F,
) -> ControlFlow<(), ()>
where
F: FnMut(PointOffsetType) -> ControlFlow<(), ()>,
{
let links = self.links_layers[point_id as usize][level].read();
let ready_list = self.ready_list.read();
for link in links.iter() {
if ready_list[link as usize] {
f(link)?;
}
}
ControlFlow::Continue(())
}

This comment was marked as off-topic.

let hop1_limit = self.get_m(level);
let hop2_limit = self.get_m(level);
debug_assert_ne!(self.get_m(level), 0); // See `FilteredBytesScorer::score_points`

This comment was marked as off-topic.

Comment on lines +957 to 964
let acorn_enabled = params
.and_then(|params| params.acorn)
.is_some_and(|acorn| acorn.enable);
let acorn_max_selectivity = params
.and_then(|params| params.acorn)
.and_then(|acorn| acorn.max_selectivity)
.map_or(ACORN_MAX_SELECTIVITY_DEFAULT, |v| *v);

This comment was marked as off-topic.

Comment on lines +526 to +530
/// Default value for [`AcornSearchParams::max_selectivity`].
///
/// After change, update docs for GRPC and REST API.
pub const ACORN_MAX_SELECTIVITY_DEFAULT: f64 = 0.4;

This comment was marked as off-topic.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

♻️ Duplicate comments (2)
lib/segment/src/index/hnsw_index/hnsw.rs (1)

957-1005: Critical: Fix OrderedFloat access; verify selectivity threshold semantics.

Line 963 attempts to dereference OrderedFloat with *v, which will not compile because OrderedFloat does not implement Deref.

Apply this fix:

 let acorn_max_selectivity = params
     .and_then(|params| params.acorn)
     .and_then(|acorn| acorn.max_selectivity)
-    .map_or(ACORN_MAX_SELECTIVITY_DEFAULT, |v| *v);
+    .map_or(ACORN_MAX_SELECTIVITY_DEFAULT, |v| v.0);

Additionally, verify the ACORN selectivity calculation aligns with the paper's definition. The current implementation computes query_cardinality.exp / available_vector_count and compares against acorn_max_selectivity. Ensure this threshold semantics matches the intended behavior described in the ACORN paper (e.g., whether selectivity should represent the fraction of vectors passing the filter).

lib/segment/src/index/hnsw_index/graph_layers.rs (1)

154-243: Minor: Misleading debug_assert comment; verify 2-hop budget semantics.

Line 182's comment references FilteredBytesScorer::score_points, but this function calls FilteredScorer::score_points_unfiltered at line 238. Update the comment to clarify the actual invariant.

Suggested fix:

-        debug_assert_ne!(self.get_m(level), 0); // See `FilteredBytesScorer::score_points`
+        // ACORN uses M to compute per-hop budgets; M must not be zero.
+        debug_assert_ne!(self.get_m(level), 0);

Additionally, verify the 2-hop limit semantics. At line 218, total_limit = to_score.len() + hop2_limit means each filtered 1-hop node can contribute up to hop2_limit additional 2-hop neighbors. Confirm this matches the ACORN-1 paper's definition of "limit factor" to avoid scoring too many or too few candidates.

Run the following script to check how get_m is used across the codebase and ensure the assertion holds:

#!/bin/bash
# Verify that get_m(level) is always non-zero when used in ACORN scoring
rg -nP '\bget_m\(' --type=rs -A2 -B2
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 14bfb23 and c88e115.

📒 Files selected for processing (8)
  • docs/grpc/docs.md (3 hunks)
  • docs/redoc/master/openapi.json (2 hunks)
  • lib/api/build.rs (2 hunks)
  • lib/api/src/grpc/proto/points.proto (2 hunks)
  • lib/api/src/grpc/qdrant.rs (2 hunks)
  • lib/segment/src/index/hnsw_index/graph_layers.rs (10 hunks)
  • lib/segment/src/index/hnsw_index/hnsw.rs (5 hunks)
  • lib/segment/src/types.rs (2 hunks)
🚧 Files skipped from review as they are similar to previous changes (4)
  • lib/segment/src/types.rs
  • docs/redoc/master/openapi.json
  • lib/api/build.rs
  • lib/api/src/grpc/proto/points.proto
🧰 Additional context used
📓 Path-based instructions (2)
**/*.rs

📄 CodeRabbit inference engine (.github/review-rules.md)

**/*.rs: Prefer explicit SomeType::from(x) over implicit x.into() in Rust code
Do not use transmute_from_u8, transmute_to_u8, transmute_from_u8_to_slice, transmute_from_u8_to_mut_slice, transmute_to_u8_slice in new code; use bytemuck or zerocopy instead

Files:

  • lib/segment/src/index/hnsw_index/graph_layers.rs
  • lib/api/src/grpc/qdrant.rs
  • lib/segment/src/index/hnsw_index/hnsw.rs
**/src/**/*.rs

📄 CodeRabbit inference engine (.github/review-rules.md)

**/src/**/*.rs: Prefer exhaustive match arms over a catch-all _ arm to avoid missing new enum variants (except in tests/benchmarks or when provably safe)
Prefer explicit field ignoring with : _ over .. in struct patterns (except in tests/benchmarks or when provably safe)

Files:

  • lib/segment/src/index/hnsw_index/graph_layers.rs
  • lib/api/src/grpc/qdrant.rs
  • lib/segment/src/index/hnsw_index/hnsw.rs
🧬 Code graph analysis (3)
lib/segment/src/index/hnsw_index/graph_layers.rs (1)
lib/segment/src/index/hnsw_index/graph_layers_builder.rs (3)
  • get_visited_list_from_pool (54-56)
  • for_each_link (58-69)
  • try_for_each_link (71-88)
lib/api/src/grpc/qdrant.rs (1)
lib/api/src/grpc/validate.rs (8)
  • validate (14-14)
  • validate (19-21)
  • validate (29-31)
  • validate (39-41)
  • validate (49-54)
  • validate (58-64)
  • validate (68-74)
  • validate (78-85)
lib/segment/src/index/hnsw_index/hnsw.rs (3)
lib/segment/src/vector_storage/vector_storage_base.rs (17)
  • v (660-660)
  • v (662-662)
  • v (664-664)
  • v (665-665)
  • v (667-667)
  • v (669-669)
  • v (670-670)
  • v (671-671)
  • v (672-672)
  • v (673-673)
  • v (674-674)
  • v (675-675)
  • v (676-676)
  • v (677-677)
  • v (678-678)
  • v (944-944)
  • available_vector_count (82-85)
lib/segment/src/index/query_estimator.rs (1)
  • adjust_to_available_vectors (25-65)
lib/segment/src/index/hnsw_index/graph_layers.rs (1)
  • search_with_vectors (564-596)
🪛 markdownlint-cli2 (0.18.1)
docs/grpc/docs.md

140-140: Unordered list indentation
Expected: 2; Actual: 4

(MD007, ul-indent)


2449-2449: Table pipe style
Expected: leading_and_trailing; Actual: leading_only; Missing trailing pipe

(MD055, table-pipe-style)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (11)
  • GitHub Check: rust-tests (windows-latest)
  • GitHub Check: lint
  • GitHub Check: test-consistency
  • GitHub Check: e2e-tests
  • GitHub Check: test-shard-snapshot-api-s3-minio
  • GitHub Check: test-consensus-compose
  • GitHub Check: integration-tests-consensus
  • GitHub Check: rust-tests (macos-latest)
  • GitHub Check: rust-tests-no-rocksdb (ubuntu-latest)
  • GitHub Check: integration-tests
  • GitHub Check: rust-tests (ubuntu-latest)
🔇 Additional comments (14)
docs/grpc/docs.md (2)

2441-2462: Verify value ranges for max_selectivity.

The documentation states that max_selectivity should be "0.0 for never, 1.0 for always" with a default of 0.4. Confirm that the implementation enforces these bounds (0.0 ≤ value ≤ 1.0) to prevent invalid configurations. If validation exists upstream, consider adding a validation note to the docs for clarity.


2441-2462: Documentation accurately reflects AcornSearchParams API.

The proto documentation correctly documents the new AcornSearchParams message and its integration into SearchParams. Field names, types, and descriptions align with the implementation. The design has evolved from the earlier acorn_limit_factor parameter proposal to a more granular approach with separate enable and max_selectivity controls, which is consistent with the PR objective that noted the design may change.

Also applies to: 4764-4764

lib/api/src/grpc/qdrant.rs (2)

4853-4871: Nice validation bounds on max_selectivity. This cleanly matches the ACORN paper’s 0–1 contract and keeps bad configs out at the API edge.


4893-4896: Nested validation hook is exactly what we needed. With #[validate(nested)] on acorn, the top-level validator now respects the new struct, so we won’t regress on input sanitization.

lib/segment/src/index/hnsw_index/hnsw.rs (4)

46-48: LGTM: Import changes align with ACORN integration.

The new imports for SearchAlgorithm and GraphLayersWithVectors are correctly positioned and support the algorithm selection logic added downstream.


66-67: LGTM: ACORN-related imports added.

The imports for ACORN_MAX_SELECTIVITY_DEFAULT and the updated SearchParams type correctly support the new ACORN threshold logic.


1007-1012: LGTM: Early return for unimplemented ACORN-with-vectors path is appropriate.

The comment clearly documents that ACORN is not yet implemented for graph-with-vectors, and the early return prevents silent failures.


1067-1074: LGTM: Algorithm propagation to graph search is correct.

The selected algorithm is correctly passed to the underlying graph.search() call, enabling the dispatch logic in GraphLayers.

lib/segment/src/index/hnsw_index/graph_layers.rs (6)

1-26: LGTM: Module documentation clearly describes search variants.

The new module-level documentation provides a helpful overview of the five search functions and their differences, improving code navigability.


29-29: LGTM: ControlFlow import supports early-exit iteration.

The ControlFlow import is necessary for the new try_for_each_link API.


80-84: LGTM: SearchAlgorithm enum is well-defined.

The public enum with Hnsw and Acorn variants provides a clear, type-safe way to select the search strategy. Deriving Copy, Eq, and PartialEq is appropriate for a simple discriminant.


93-100: LGTM: try_for_each_link enables early-exit iteration.

The new trait method allows callers to short-circuit link iteration, which is essential for the ACORN algorithm's per-hop budgets.


466-476: LGTM: try_for_each_link delegation is correct.

The implementation correctly delegates to self.links.links(point_id, level).try_for_each(f), propagating the ControlFlow result.


530-561: LGTM: Algorithm dispatch logic is sound.

The search function correctly accepts the new algorithm parameter and dispatches to either search_on_level (Hnsw) or search_on_level_acorn (Acorn) using exhaustive matching. The max(ef, top) normalization at line 551 is appropriate.

- [NullValue](#qdrant-NullValue)

- [points.proto](#points-proto)
- [AcornSearchParams](#qdrant-AcornSearchParams)
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Fix indentation in TOC entry.

The unordered list item should use 2-space indentation per Markdown conventions, not 4 spaces.

    - [AcornSearchParams](#qdrant-AcornSearchParams)
-    - [BatchResult](#qdrant-BatchResult)
+  - [BatchResult](#qdrant-BatchResult)

Committable suggestion skipped: line range outside the PR's diff.

🧰 Tools
🪛 markdownlint-cli2 (0.18.1)

140-140: Unordered list indentation
Expected: 2; Actual: 4

(MD007, ul-indent)

🤖 Prompt for AI Agents
In docs/grpc/docs.md around line 140, the TOC entry "-
[AcornSearchParams](#qdrant-AcornSearchParams)" is indented with 4 spaces;
change it to use 2-space indentation for the unordered list item so it follows
the Markdown convention (replace the 4-space indent with 2 spaces).


| Field | Type | Label | Description |
| ----- | ---- | ----- | ----------- |
| enable | [bool](#bool) | optional | If true, then ACORN may be used for the HNSW search based on filters selectivity.
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Add missing trailing pipe to table header.

Markdown table formatting requires consistent leading and trailing pipes. Line 2449 is missing the trailing pipe.

~| ----- | ---- | ----- | ----------- |
~| ----- | ---- | ----- | ----------- |

Committable suggestion skipped: line range outside the PR's diff.

🧰 Tools
🪛 markdownlint-cli2 (0.18.1)

2449-2449: Table pipe style
Expected: leading_and_trailing; Actual: leading_only; Missing trailing pipe

(MD055, table-pipe-style)

🤖 Prompt for AI Agents
In docs/grpc/docs.md around line 2449, the markdown table row for the "enable"
field is missing a trailing pipe; edit the line to add the closing "|" so the
table cells are properly delimited and the table renders correctly.

@xzfc xzfc merged commit c1c9eb9 into dev Oct 24, 2025
15 checks passed
@xzfc xzfc deleted the acorn branch October 24, 2025 19:09
timvisee pushed a commit that referenced this pull request Nov 14, 2025
* GraphLayersBase::try_for_each_link

* Add FilteredScorer::score_points_unfiltered

* Add GraphLayersBase::search_on_level_acorn()

* Add SearchParams::acorn API parameter

* Integrate ACORN to HNSW search

* Add doc

* review fixes

* Misc fixes

---------

Co-authored-by: generall <andrey@vasnetsov.com>
@timvisee timvisee mentioned this pull request Nov 14, 2025
@coderabbitai coderabbitai bot mentioned this pull request Dec 4, 2025
9 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants