weavatrix_search_vector/
distributed.rs1use crate::error::SearchError;
2use crate::hit::SearchHit;
3use crate::hnsw::VectorIndex;
4use crate::mutable::MutableVectorIndex;
5use crate::quantized::{QuantizedIndex, ScalarQuantizedIndex};
6use crate::storage::MappedVectorIndex;
7use std::collections::BTreeMap;
8use std::sync::Arc;
9
10pub trait SearchShard: Send + Sync {
15 fn dimensions(&self) -> usize;
16
17 fn search_shard(&self, query: &[f32], count: usize) -> Result<Vec<SearchHit>, SearchError>;
23}
24
25impl SearchShard for VectorIndex {
26 fn dimensions(&self) -> usize {
27 self.dimensions()
28 }
29
30 fn search_shard(&self, query: &[f32], count: usize) -> Result<Vec<SearchHit>, SearchError> {
31 self.search(query, count)
32 }
33}
34
35impl SearchShard for MappedVectorIndex {
36 fn dimensions(&self) -> usize {
37 self.dimensions()
38 }
39
40 fn search_shard(&self, query: &[f32], count: usize) -> Result<Vec<SearchHit>, SearchError> {
41 self.search(query, count)
42 }
43}
44
45impl SearchShard for ScalarQuantizedIndex {
46 fn dimensions(&self) -> usize {
47 self.dimensions()
48 }
49
50 fn search_shard(&self, query: &[f32], count: usize) -> Result<Vec<SearchHit>, SearchError> {
51 self.search(query, count)
52 }
53}
54
55impl SearchShard for QuantizedIndex {
56 fn dimensions(&self) -> usize {
57 self.dimensions()
58 }
59
60 fn search_shard(&self, query: &[f32], count: usize) -> Result<Vec<SearchHit>, SearchError> {
61 self.search(query, count)
62 }
63}
64
65impl SearchShard for MutableVectorIndex {
66 fn dimensions(&self) -> usize {
67 self.config().dimensions
68 }
69
70 fn search_shard(&self, query: &[f32], count: usize) -> Result<Vec<SearchHit>, SearchError> {
71 self.search(query, count)
72 }
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub struct DistributedConfig {
78 pub worker_budget: usize,
80 pub candidate_multiplier: usize,
82}
83
84impl DistributedConfig {
85 #[must_use]
86 pub const fn new(worker_budget: usize) -> Self {
87 Self {
88 worker_budget,
89 candidate_multiplier: 2,
90 }
91 }
92
93 fn validate(self) -> Result<Self, SearchError> {
94 if self.worker_budget == 0 {
95 return Err(SearchError::InvalidConfig(
96 "distributed worker_budget must be non-zero",
97 ));
98 }
99 if self.candidate_multiplier == 0 {
100 return Err(SearchError::InvalidConfig(
101 "distributed candidate_multiplier must be non-zero",
102 ));
103 }
104 Ok(self)
105 }
106}
107
108pub struct DistributedIndex {
110 dimensions: usize,
111 config: DistributedConfig,
112 shards: Vec<Arc<dyn SearchShard>>,
113}
114
115impl std::fmt::Debug for DistributedIndex {
116 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
117 formatter
118 .debug_struct("DistributedIndex")
119 .field("dimensions", &self.dimensions)
120 .field("config", &self.config)
121 .field("shards", &self.shards.len())
122 .finish()
123 }
124}
125
126impl DistributedIndex {
127 pub fn new(dimensions: usize, config: DistributedConfig) -> Result<Self, SearchError> {
133 if dimensions == 0 {
134 return Err(SearchError::InvalidConfig(
135 "distributed dimensions must be non-zero",
136 ));
137 }
138 Ok(Self {
139 dimensions,
140 config: config.validate()?,
141 shards: Vec::new(),
142 })
143 }
144
145 pub fn push(&mut self, shard: Arc<dyn SearchShard>) -> Result<(), SearchError> {
151 if shard.dimensions() != self.dimensions {
152 return Err(SearchError::DimensionMismatch {
153 expected: self.dimensions,
154 actual: shard.dimensions(),
155 vector: None,
156 });
157 }
158 self.shards.push(shard);
159 Ok(())
160 }
161
162 #[must_use]
163 pub fn len(&self) -> usize {
164 self.shards.len()
165 }
166
167 #[must_use]
168 pub fn is_empty(&self) -> bool {
169 self.shards.is_empty()
170 }
171
172 pub fn search(&self, query: &[f32], count: usize) -> Result<Vec<SearchHit>, SearchError> {
179 self.search_with_workers(query, count, self.config.worker_budget)
180 }
181
182 pub fn search_batch(
189 &self,
190 queries: &[&[f32]],
191 count: usize,
192 ) -> Result<Vec<Vec<SearchHit>>, SearchError> {
193 if queries.is_empty() {
194 return Ok(Vec::new());
195 }
196 let query_workers = self.config.worker_budget.min(queries.len()).max(1);
197 let shard_workers = (self.config.worker_budget / query_workers).max(1);
198 crate::parallel::search_batch(queries, query_workers, |query| {
199 self.search_with_workers(query, count, shard_workers)
200 })
201 }
202
203 fn search_with_workers(
204 &self,
205 query: &[f32],
206 count: usize,
207 workers: usize,
208 ) -> Result<Vec<SearchHit>, SearchError> {
209 if query.len() != self.dimensions {
210 return Err(SearchError::DimensionMismatch {
211 expected: self.dimensions,
212 actual: query.len(),
213 vector: None,
214 });
215 }
216 if count == 0 || self.shards.is_empty() {
217 return Ok(Vec::new());
218 }
219 let per_shard = count
220 .checked_mul(self.config.candidate_multiplier)
221 .ok_or(SearchError::CapacityOverflow)?;
222 let workers = workers.min(self.shards.len()).max(1);
223 let chunk_size = self.shards.len().div_ceil(workers);
224 let results = std::thread::scope(|scope| {
225 let handles = self
226 .shards
227 .chunks(chunk_size)
228 .enumerate()
229 .map(|(chunk_index, chunk)| {
230 let first = chunk_index * chunk_size;
231 scope.spawn(move || {
232 chunk
233 .iter()
234 .enumerate()
235 .map(|(offset, shard)| {
236 let shard_index = first + offset;
237 shard.search_shard(query, per_shard).map_err(|error| {
238 SearchError::ShardFailed {
239 shard: shard_index,
240 message: error.to_string(),
241 }
242 })
243 })
244 .collect::<Vec<_>>()
245 })
246 })
247 .collect::<Vec<_>>();
248 handles
249 .into_iter()
250 .map(|handle| handle.join().map_err(|_| SearchError::WorkerPanic))
251 .collect::<Result<Vec<_>, _>>()
252 })?;
253 let mut best_by_key = BTreeMap::<u64, SearchHit>::new();
254 for result in results.into_iter().flatten() {
255 for hit in result? {
256 best_by_key
257 .entry(hit.key)
258 .and_modify(|current| {
259 if hit.distance.total_cmp(¤t.distance).is_lt() {
260 *current = hit;
261 }
262 })
263 .or_insert(hit);
264 }
265 }
266 let mut hits = best_by_key.into_values().collect::<Vec<_>>();
267 hits.sort_unstable_by(|left, right| {
268 left.distance
269 .total_cmp(&right.distance)
270 .then_with(|| left.key.cmp(&right.key))
271 });
272 hits.truncate(count.min(hits.len()));
273 Ok(hits)
274 }
275}