Search using WAND algorithm for efficient top-k sparse dot-product retrieval. WAND (Weighted AND) skips candidates whose maximum possible score (sum of max posting weights) can't exceed the current k-th best score. This avoids computing full dot products for most candidates.
(
&self,
collection: &str,
query: &SparseVector,
top_k: usize,
)
| 258 | /// (sum of max posting weights) can't exceed the current k-th best score. |
| 259 | /// This avoids computing full dot products for most candidates. |
| 260 | pub fn search( |
| 261 | &self, |
| 262 | collection: &str, |
| 263 | query: &SparseVector, |
| 264 | top_k: usize, |
| 265 | ) -> crate::Result<Vec<SparseSearchResult>> { |
| 266 | if query.is_empty() || top_k == 0 { |
| 267 | return Ok(Vec::new()); |
| 268 | } |
| 269 | |
| 270 | let read_txn = self.db.begin_read().map_err(|e| crate::Error::Storage { |
| 271 | engine: "sparse_vector".into(), |
| 272 | detail: format!("read txn: {e}"), |
| 273 | })?; |
| 274 | let postings_table = |
| 275 | read_txn |
| 276 | .open_table(SPARSE_POSTINGS) |
| 277 | .map_err(|e| crate::Error::Storage { |
| 278 | engine: "sparse_vector".into(), |
| 279 | detail: format!("open postings: {e}"), |
| 280 | })?; |
| 281 | |
| 282 | // Load posting lists for all query tokens. |
| 283 | let mut term_postings: Vec<(f32, Vec<SparsePosting>)> = Vec::new(); |
| 284 | for &(token_id, query_weight) in query { |
| 285 | if query_weight.abs() < f32::EPSILON { |
| 286 | continue; |
| 287 | } |
| 288 | let key = format!("{collection}:{token_id}"); |
| 289 | if let Ok(Some(guard)) = postings_table.get(key.as_str()) { |
| 290 | let postings: Vec<SparsePosting> = |
| 291 | zerompk::from_msgpack(guard.value()).unwrap_or_default(); |
| 292 | if !postings.is_empty() { |
| 293 | term_postings.push((query_weight, postings)); |
| 294 | } |
| 295 | } |
| 296 | } |
| 297 | |
| 298 | if term_postings.is_empty() { |
| 299 | return Ok(Vec::new()); |
| 300 | } |
| 301 | |
| 302 | // Accumulate dot-product scores per document. |
| 303 | // For each query term, multiply query_weight × doc_weight. |
| 304 | let mut doc_scores: HashMap<String, f32> = HashMap::new(); |
| 305 | for (query_weight, postings) in &term_postings { |
| 306 | for posting in postings { |
| 307 | *doc_scores.entry(posting.doc_id.clone()).or_default() += |
| 308 | query_weight * posting.weight; |
| 309 | } |
| 310 | } |
| 311 | |
| 312 | // Extract top-k by score (descending). |
| 313 | let mut results: Vec<SparseSearchResult> = doc_scores |
| 314 | .into_iter() |
| 315 | .map(|(doc_id, score)| SparseSearchResult { doc_id, score }) |
| 316 | .collect(); |
| 317 |