Skip to content
Draft
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
1 change: 1 addition & 0 deletions crates/vchordrq/src/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ pub fn build<R: RelationWrite, O: Operator>(
height_of_root: structures.len() as u32,
is_residual,
rerank_in_heap: vchordrq_options.rerank_in_table,
indexed_vectors: Some(0),
centroids_first: centroids.first(),
vectors_first: vectors,
centroid_prefetch: pointer_of_centroids
Expand Down
28 changes: 21 additions & 7 deletions crates/vchordrq/src/bulkdelete.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ pub fn bulkdelete<R: RelationRead + RelationWrite, O: Operator>(
index: &R,
check: impl Fn(),
callback: impl Fn(NonZero<u64>) -> bool,
) where
) -> u64
where
R::Page: Page<Opaque = Opaque>,
{
let meta_guard = index.read(0);
Expand All @@ -38,6 +39,8 @@ pub fn bulkdelete<R: RelationRead + RelationWrite, O: Operator>(

drop(meta_guard);

let mut live = 0_u64;

let step = |state: State| {
let mut results = Vec::new();
for first in state {
Expand All @@ -64,19 +67,21 @@ pub fn bulkdelete<R: RelationRead + RelationWrite, O: Operator>(
while current != u32::MAX {
check();
let read = index.read(current);
let flag = 'flag: {
let (flag, page_live) = 'scan: {
let mut page_live = 0_u64;
for i in 1..=read.len() {
let bytes = read.get(i).expect("data corruption");
let tuple = FrozenTuple::deserialize_ref(bytes);
if let FrozenTupleReader::_0(tuple) = tuple {
for p in tuple.payload().iter() {
if Some(true) == p.map(&callback) {
break 'flag true;
break 'scan (true, 0);
}
page_live += u64::from(p.is_some());
}
}
}
false
(false, page_live)
};
if flag {
drop(read);
Expand All @@ -89,9 +94,12 @@ pub fn bulkdelete<R: RelationRead + RelationWrite, O: Operator>(
if Some(true) == p.map(&callback) {
*p = None;
}
live += u64::from(p.is_some());
}
}
}
} else {
live += page_live;
}
current = directory.next().unwrap_or(u32::MAX);
}
Expand All @@ -101,16 +109,18 @@ pub fn bulkdelete<R: RelationRead + RelationWrite, O: Operator>(
while current != u32::MAX {
check();
let read = index.read(current);
let flag = 'flag: {
let (flag, page_live) = 'scan: {
let mut page_live = 0_u64;
for i in 1..=read.len() {
let bytes = read.get(i).expect("data corruption");
let tuple = AppendableTuple::deserialize_ref(bytes);
let p = tuple.payload();
if Some(true) == p.map(&callback) {
break 'flag true;
break 'scan (true, 0);
}
page_live += u64::from(p.is_some());
}
false
(false, page_live)
};
if flag {
drop(read);
Expand All @@ -122,14 +132,18 @@ pub fn bulkdelete<R: RelationRead + RelationWrite, O: Operator>(
if Some(true) == p.map(&callback) {
*p = None;
}
live += u64::from(p.is_some());
}
current = write.get_opaque().next;
} else {
live += page_live;
current = read.get_opaque().next;
}
}
}
}

live
}

pub fn bulkdelete_vectors<R: RelationRead + RelationWrite, O: Operator>(
Expand Down
8 changes: 7 additions & 1 deletion crates/vchordrq/src/cost.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ use index::relation::{Page, RelationRead};
pub struct Cost {
pub dim: u32,
pub cells: Vec<u32>,
pub indexed_vectors: Option<u64>,
}

#[must_use]
Expand All @@ -27,8 +28,13 @@ pub fn cost<R: RelationRead>(index: &R) -> Cost {
let meta_tuple = MetaTuple::deserialize_ref(meta_bytes);
let dim = meta_tuple.dim();
let cells = meta_tuple.cells().to_vec();
let indexed_vectors = meta_tuple.indexed_vectors();

drop(meta_guard);

Cost { dim, cells }
Cost {
dim,
cells,
indexed_vectors,
}
}
4 changes: 4 additions & 0 deletions crates/vchordrq/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,11 @@ mod freepages;
mod insert;
mod linked_vec;
mod maintain;
mod maxsim_cost;
mod prewarm;
mod rerank;
mod search;
mod statistics;
mod tape;
mod tape_writer;
mod tuples;
Expand All @@ -43,9 +45,11 @@ pub use cost::cost;
pub use fast_heap::FastHeap;
pub use insert::{InsertChooser, insert, insert_vector};
pub use maintain::{MaintainChooser, maintain};
pub use maxsim_cost::{MaxsimCostEstimate, MaxsimCostInput, estimate_maxsim_cost};
pub use prewarm::prewarm;
pub use rerank::{how, rerank_heap, rerank_index};
pub use search::{default_search, maxsim_search};
pub use statistics::set_indexed_vectors;

use zerocopy::{FromBytes, Immutable, IntoBytes, KnownLayout};

Expand Down
128 changes: 128 additions & 0 deletions crates/vchordrq/src/maxsim_cost.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
// This software is licensed under a dual license model:
//
// GNU Affero General Public License v3 (AGPLv3): You may use, modify, and
// distribute this software under the terms of the AGPLv3.
//
// Elastic License v2 (ELv2): You may also use, modify, and distribute this
// software under the Elastic License v2, which has specific restrictions.
//
// We welcome any commercial collaboration or support. For inquiries
// regarding the licenses, please contact us at:
// vectorchord-inquiry@tensorchord.ai
//
// Copyright (c) 2025-2026 TensorChord Inc.

#[derive(Clone, Copy, Debug)]
pub struct MaxsimCostInput {
pub heap_rows: f64,
pub index_tokens: f64,
pub token_nodes_per_query: f64,
pub base_index_pages: f64,
pub query_tokens: u32,
pub limit_tuples: Option<f64>,
pub filter_selectivity: f64,
}

#[derive(Clone, Copy, Debug)]
pub struct MaxsimCostEstimate {
pub startup_cost: f64,
pub total_cost: f64,
pub selectivity: f64,
pub index_pages: f64,
}

/// Estimate the eager token search and page aggregation performed by MaxSim.
///
/// The constants are deliberately conservative rather than hardware-specific;
/// an optional exact reranker can add its own cost in a later layer.
pub fn estimate_maxsim_cost(input: MaxsimCostInput) -> MaxsimCostEstimate {
let heap_rows = input.heap_rows.max(1.0);
let index_tokens = input.index_tokens.max(heap_rows);
let query_tokens = f64::from(input.query_tokens.max(1));
let average_document_tokens = (index_tokens / heap_rows).clamp(1.0, 65_536.0);
let token_visits = input.token_nodes_per_query.max(1.0) * query_tokens;

// Until page-level candidate statistics exist, approximate the chance that
// at least one token from a document is visited by the token index.
let token_visit_fraction = (token_visits / index_tokens).clamp(0.0, 1.0);
let candidate_probability = 1.0 - (1.0 - token_visit_fraction).powf(average_document_tokens);
let generated_pages = (heap_rows * candidate_probability).clamp(1.0, heap_rows);

let returned_pages = input
.limit_tuples
.map(|limit| limit.max(1.0) / input.filter_selectivity.clamp(1e-9, 1.0))
.unwrap_or(generated_pages)
.min(generated_pages);

// Search and aggregation are eager in the current scanner, so LIMIT does
// not remove this work from startup cost.
let startup_cost = 0.001 * token_visits + 0.01 * token_visits + 0.05 * generated_pages;
let total_cost = startup_cost + returned_pages;
let selectivity = (returned_pages / heap_rows).clamp(1e-9, 1.0);
let index_pages =
input.base_index_pages.max(1.0) * (1.0 + 0.25 * (query_tokens - 1.0).max(0.0));

MaxsimCostEstimate {
startup_cost,
total_cost,
selectivity,
index_pages,
}
}

#[cfg(test)]
mod tests {
use super::*;

fn input() -> MaxsimCostInput {
MaxsimCostInput {
heap_rows: 10_000.0,
index_tokens: 1_000_000.0,
token_nodes_per_query: 2_000.0,
base_index_pages: 1_000.0,
query_tokens: 16,
limit_tuples: Some(20.0),
filter_selectivity: 1.0,
}
}

#[test]
fn maxsim_cost_is_finite_and_nonzero() {
let estimate = estimate_maxsim_cost(input());
assert!(estimate.startup_cost > 0.0);
assert!(estimate.total_cost >= estimate.startup_cost);
assert!((1e-9..=1.0).contains(&estimate.selectivity));
assert!(estimate.index_pages >= 1.0);
}

#[test]
fn query_token_count_increases_eager_work() {
let one = estimate_maxsim_cost(MaxsimCostInput {
query_tokens: 1,
..input()
});
let many = estimate_maxsim_cost(MaxsimCostInput {
query_tokens: 64,
..input()
});
assert!(many.startup_cost > one.startup_cost);
assert!(many.index_pages > one.index_pages);
}

#[test]
fn missing_statistics_remain_finite() {
let estimate = estimate_maxsim_cost(MaxsimCostInput {
heap_rows: -1.0,
index_tokens: 0.0,
token_nodes_per_query: 0.0,
base_index_pages: 0.0,
filter_selectivity: 0.0,
limit_tuples: None,
..input()
});
assert!(estimate.startup_cost.is_finite());
assert!(estimate.total_cost.is_finite());
assert!(estimate.selectivity.is_finite());
assert!(estimate.index_pages.is_finite());
}
}
30 changes: 30 additions & 0 deletions crates/vchordrq/src/statistics.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// This software is licensed under a dual license model:
//
// GNU Affero General Public License v3 (AGPLv3): You may use, modify, and
// distribute this software under the terms of the AGPLv3.
//
// Elastic License v2 (ELv2): You may also use, modify, and distribute this
// software under the Elastic License v2, which has specific restrictions.
//
// We welcome any commercial collaboration or support. For inquiries
// regarding the licenses, please contact us at:
// vectorchord-inquiry@tensorchord.ai
//
// Copyright (c) 2025-2026 TensorChord Inc.

use crate::tuples::{MetaTuple, WithWriter};
use index::relation::{Page, RelationWrite};

/// Store the number of live vector nodes observed by the latest complete
/// build or vacuum pass.
///
/// This is deliberately refreshed in bulk instead of on every insert. A
/// per-insert update would serialize all writers on the metapage, which is a
/// poor tradeoff for a planner statistic. Like PostgreSQL's relation
/// statistics, the value may be stale between maintenance passes.
pub fn set_indexed_vectors<R: RelationWrite>(index: &R, indexed_vectors: u64) {
let mut meta_guard = index.write(0, false);
let meta_bytes = meta_guard.get_mut(1).expect("data corruption");
let mut meta_tuple = MetaTuple::deserialize_mut(meta_bytes);
meta_tuple.set_indexed_vectors(indexed_vectors);
}
Loading
Loading