1use crate::backend::types::{FilterExpr, Scalar};
27use anyhow::{Result, anyhow};
28use arrow_array::types::{Float32Type, UInt8Type, UInt64Type};
29use arrow_array::{
30 Array, Float32Array, ListArray, RecordBatch, RecordBatchIterator, StructArray, UInt8Array,
31 UInt32Array, UInt64Array,
32};
33use arrow_schema::{DataType, Field, Schema as ArrowSchema};
34use futures::TryStreamExt;
35use lance::Dataset;
36use std::cmp::Reverse;
37use std::collections::{BinaryHeap, HashMap, HashSet};
38use std::sync::Arc;
39use tracing::{debug, info, instrument};
40use uni_common::core::id::Vid;
41use uni_common::core::schema::SparseVectorIndexConfig;
42
43const DEFAULT_MAX_POSTINGS_MEMORY: usize = 256 * 1024 * 1024;
46
47type Postings = HashMap<u32, Vec<(u64, f32)>>;
49
50fn estimated_postings_memory(postings: &Postings) -> usize {
52 postings
53 .values()
54 .map(|v| std::mem::size_of::<u32>() + std::mem::size_of::<Vec<(u64, f32)>>() + v.len() * 12)
55 .sum()
56}
57
58fn merge_postings_segments(segments: Vec<Postings>) -> Postings {
60 let mut merged: Postings = HashMap::new();
61 for segment in segments {
62 for (term, entries) in segment {
63 merged.entry(term).or_default().extend(entries);
64 }
65 }
66 merged
67}
68
69fn read_sparse_row(struct_arr: &StructArray, row: usize) -> Option<(Vec<u32>, Vec<f32>)> {
73 if struct_arr.is_null(row) {
74 return None;
75 }
76 let indices_list = struct_arr
77 .column_by_name("indices")?
78 .as_any()
79 .downcast_ref::<ListArray>()?;
80 let values_list = struct_arr
81 .column_by_name("values")?
82 .as_any()
83 .downcast_ref::<ListArray>()?;
84 let idx_vals = indices_list.value(row);
85 let idx_arr = idx_vals.as_any().downcast_ref::<UInt32Array>()?;
86 let w_vals = values_list.value(row);
87 let w_arr = w_vals.as_any().downcast_ref::<Float32Array>()?;
88 let indices = (0..idx_arr.len()).map(|i| idx_arr.value(i)).collect();
89 let values = (0..w_arr.len()).map(|i| w_arr.value(i)).collect();
90 Some((indices, values))
91}
92
93const QUANT_LEVELS: f32 = 255.0;
99
100fn quantize_term(weights: &[f32]) -> (Vec<u8>, f32, f32) {
110 let max_weight = weights.iter().copied().fold(0.0f32, f32::max);
111 if max_weight <= 0.0 {
114 return (vec![0u8; weights.len()], 0.0, 0.0);
115 }
116 let scale = max_weight / QUANT_LEVELS;
117 let codes: Vec<u8> = weights
118 .iter()
119 .map(|&w| {
120 (w.clamp(0.0, max_weight) / scale).round() as u8
124 })
125 .collect();
126 let max_code = codes.iter().copied().max().unwrap_or(0);
127 (codes, scale, dequantize(max_code, scale))
128}
129
130fn dequantize(code: u8, scale: f32) -> f32 {
132 f32::from(code) * scale
133}
134
135enum TermWeights<'a> {
139 Quantized { codes: &'a UInt8Array, scale: f32 },
140 Lossless(&'a Float32Array),
141}
142
143impl TermWeights<'_> {
144 fn get(&self, j: usize) -> f32 {
146 match self {
147 Self::Quantized { codes, scale } => {
148 if codes.is_null(j) {
149 0.0
150 } else {
151 dequantize(codes.value(j), *scale)
152 }
153 }
154 Self::Lossless(arr) => {
155 if arr.is_null(j) {
156 0.0
157 } else {
158 arr.value(j)
159 }
160 }
161 }
162 }
163}
164
165fn term_weights(weights_arr: &dyn Array, row_scale: Option<f32>) -> Result<TermWeights<'_>> {
174 if let Some(codes) = weights_arr.as_any().downcast_ref::<UInt8Array>() {
175 let scale = row_scale
176 .ok_or_else(|| anyhow!("Quantized sparse weights missing weight_scale column"))?;
177 Ok(TermWeights::Quantized { codes, scale })
178 } else if let Some(arr) = weights_arr.as_any().downcast_ref::<Float32Array>() {
179 Ok(TermWeights::Lossless(arr))
180 } else {
181 Err(anyhow!(
182 "Invalid inner weights type: {:?}",
183 weights_arr.data_type()
184 ))
185 }
186}
187
188fn weight_scale_column(batch: &RecordBatch) -> Option<&Float32Array> {
192 batch
193 .column_by_name("weight_scale")
194 .and_then(|c| c.as_any().downcast_ref::<Float32Array>())
195}
196
197pub struct SparseVectorIndex {
199 dataset: Option<Dataset>,
200 base_uri: String,
201 label: String,
202 property: String,
203 config: SparseVectorIndexConfig,
204}
205
206impl std::fmt::Debug for SparseVectorIndex {
207 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
208 f.debug_struct("SparseVectorIndex")
209 .field("base_uri", &self.base_uri)
210 .field("label", &self.label)
211 .field("property", &self.property)
212 .field("initialized", &self.dataset.is_some())
213 .finish_non_exhaustive()
214 }
215}
216
217impl SparseVectorIndex {
218 fn postings_path(base_uri: &str, label: &str, property: &str) -> String {
220 format!("{base_uri}/indexes/{label}/{property}_sparse")
221 }
222
223 pub async fn new(base_uri: &str, config: SparseVectorIndexConfig) -> Result<Self> {
225 let path = Self::postings_path(base_uri, &config.label, &config.property);
226 let dataset = (Dataset::open(&path).await).ok();
227 Ok(Self {
228 dataset,
229 base_uri: base_uri.to_string(),
230 label: config.label.clone(),
231 property: config.property.clone(),
232 config,
233 })
234 }
235
236 fn accumulate_batch(&self, batch: &RecordBatch, postings: &mut Postings) -> Result<usize> {
241 let vid_col = batch
242 .column_by_name("_vid")
243 .ok_or_else(|| anyhow!("Missing _vid"))?
244 .as_any()
245 .downcast_ref::<UInt64Array>()
246 .ok_or_else(|| anyhow!("Invalid _vid type"))?;
247 let term_col = batch
248 .column_by_name(&self.property)
249 .ok_or_else(|| anyhow!("Missing property {}", self.property))?;
250 let struct_arr = term_col
251 .as_any()
252 .downcast_ref::<StructArray>()
253 .ok_or_else(|| {
254 anyhow!(
255 "Property {} must be a sparse-vector struct, got {:?}",
256 self.property,
257 term_col.data_type()
258 )
259 })?;
260 let mut count = 0;
261 for i in 0..batch.num_rows() {
262 let vid = vid_col.value(i);
263 let Some((indices, values)) = read_sparse_row(struct_arr, i) else {
264 continue;
265 };
266 for (term, weight) in indices.into_iter().zip(values) {
267 if !weight.is_finite() {
272 continue;
273 }
274 postings.entry(term).or_default().push((vid, weight));
275 }
276 count += 1;
277 }
278 Ok(count)
279 }
280
281 async fn finish_build(
283 &mut self,
284 postings: Postings,
285 mut temp_segments: Vec<Postings>,
286 ) -> Result<()> {
287 if temp_segments.is_empty() {
288 self.write_postings(postings).await
289 } else {
290 temp_segments.push(postings);
291 info!(
292 segments = temp_segments.len(),
293 "Merging sparse postings segments"
294 );
295 let merged = merge_postings_segments(temp_segments);
296 self.write_postings(merged).await
297 }
298 }
299
300 pub async fn build_from_batches(
306 &mut self,
307 batches: &[RecordBatch],
308 progress: impl Fn(usize),
309 ) -> Result<()> {
310 let mut postings: Postings = HashMap::new();
311 let mut temp_segments: Vec<Postings> = Vec::new();
312 let mut count = 0;
313 for batch in batches {
314 count += self.accumulate_batch(batch, &mut postings)?;
315 progress(count);
316 if estimated_postings_memory(&postings) > DEFAULT_MAX_POSTINGS_MEMORY {
317 temp_segments.push(std::mem::take(&mut postings));
318 }
319 }
320 self.finish_build(postings, temp_segments).await
321 }
322
323 async fn write_postings(&mut self, postings: Postings) -> Result<()> {
331 let quantize = self.config.quantize;
332 let n = postings.len();
333 let mut term_ids = Vec::with_capacity(n);
334 let mut vid_lists: Vec<Option<Vec<Option<u64>>>> = Vec::with_capacity(n);
335 let mut max_impacts = Vec::with_capacity(n);
336 let mut q_weight_lists: Vec<Option<Vec<Option<u8>>>> = Vec::new();
338 let mut q_scales: Vec<f32> = Vec::new();
339 let mut f_weight_lists: Vec<Option<Vec<Option<f32>>>> = Vec::new();
340
341 for (term, entries) in postings {
342 let mut vids = Vec::with_capacity(entries.len());
343 let mut weights = Vec::with_capacity(entries.len());
344 for (vid, weight) in entries {
345 vids.push(Some(vid));
346 weights.push(weight);
347 }
348 term_ids.push(term);
349 vid_lists.push(Some(vids));
350
351 if quantize {
352 let (codes, scale, max_impact) = quantize_term(&weights);
353 q_weight_lists.push(Some(codes.into_iter().map(Some).collect()));
354 q_scales.push(scale);
355 max_impacts.push(max_impact);
356 } else {
357 let mut max_impact = f32::NEG_INFINITY;
361 for &w in &weights {
362 if w > max_impact {
363 max_impact = w;
364 }
365 }
366 if !max_impact.is_finite() {
367 max_impact = 0.0;
368 }
369 max_impacts.push(max_impact);
370 f_weight_lists.push(Some(weights.into_iter().map(Some).collect()));
371 }
372 }
373
374 let term_array = UInt32Array::from(term_ids);
375 let vid_list_array = ListArray::from_iter_primitive::<UInt64Type, _, _>(vid_lists);
376 let max_impact_array = Float32Array::from(max_impacts);
377
378 let mut columns: Vec<(&str, Arc<dyn Array>)> = vec![
379 ("term_id", Arc::new(term_array) as Arc<dyn Array>),
380 ("vids", Arc::new(vid_list_array) as Arc<dyn Array>),
381 ];
382 if quantize {
383 let weight_list_array =
384 ListArray::from_iter_primitive::<UInt8Type, _, _>(q_weight_lists);
385 columns.push(("weights", Arc::new(weight_list_array) as Arc<dyn Array>));
386 columns.push(("max_impact", Arc::new(max_impact_array) as Arc<dyn Array>));
387 columns.push((
388 "weight_scale",
389 Arc::new(Float32Array::from(q_scales)) as Arc<dyn Array>,
390 ));
391 } else {
392 let weight_list_array =
393 ListArray::from_iter_primitive::<Float32Type, _, _>(f_weight_lists);
394 columns.push(("weights", Arc::new(weight_list_array) as Arc<dyn Array>));
395 columns.push(("max_impact", Arc::new(max_impact_array) as Arc<dyn Array>));
396 }
397
398 let batch = arrow_array::RecordBatch::try_from_iter(columns)?;
399
400 let path = Self::postings_path(&self.base_uri, &self.label, &self.property);
401 let write_params = lance::dataset::WriteParams {
402 mode: lance::dataset::WriteMode::Overwrite,
403 ..Default::default()
404 };
405 let iterator = RecordBatchIterator::new(vec![Ok(batch)], Self::postings_schema(quantize));
406 let ds = Dataset::write(iterator, &path, Some(write_params)).await?;
407 self.dataset = Some(ds);
408 Ok(())
409 }
410
411 fn postings_schema(quantize: bool) -> Arc<ArrowSchema> {
413 let weights_item = if quantize {
414 DataType::UInt8
415 } else {
416 DataType::Float32
417 };
418 let mut fields = vec![
419 Field::new("term_id", DataType::UInt32, false),
420 Field::new(
421 "vids",
422 DataType::List(Arc::new(Field::new("item", DataType::UInt64, true))),
423 false,
424 ),
425 Field::new(
426 "weights",
427 DataType::List(Arc::new(Field::new("item", weights_item, true))),
428 false,
429 ),
430 Field::new("max_impact", DataType::Float32, false),
431 ];
432 if quantize {
433 fields.push(Field::new("weight_scale", DataType::Float32, false));
434 }
435 Arc::new(ArrowSchema::new(fields))
436 }
437
438 pub async fn query_topk(&self, query: &[(u32, f32)], k: usize) -> Result<Vec<(Vid, f32)>> {
446 let Some(ds) = &self.dataset else {
447 debug!("Sparse index not initialized, returning empty result");
448 return Ok(Vec::new());
449 };
450 if query.is_empty() || k == 0 {
451 return Ok(Vec::new());
452 }
453
454 let query_weights: HashMap<u32, f32> = query.iter().copied().collect();
455 let filter = FilterExpr::one_of(
456 "term_id",
457 query_weights.keys().map(|t| Scalar::UInt(u64::from(*t))),
458 )
459 .to_sql()?;
460
461 let mut scanner = ds.scan();
462 scanner.filter(&filter)?;
463 let mut stream = scanner.try_into_stream().await?;
464
465 let mut scores: HashMap<u64, f32> = HashMap::new();
466 while let Some(batch) = stream.try_next().await? {
467 let term_col = batch
468 .column_by_name("term_id")
469 .ok_or_else(|| anyhow!("Missing term_id column"))?
470 .as_any()
471 .downcast_ref::<UInt32Array>()
472 .ok_or_else(|| anyhow!("Invalid term_id column"))?;
473 let vids_col = batch
474 .column_by_name("vids")
475 .ok_or_else(|| anyhow!("Missing vids column"))?
476 .as_any()
477 .downcast_ref::<ListArray>()
478 .ok_or_else(|| anyhow!("Invalid vids column"))?;
479 let weights_col = batch
480 .column_by_name("weights")
481 .ok_or_else(|| anyhow!("Missing weights column"))?
482 .as_any()
483 .downcast_ref::<ListArray>()
484 .ok_or_else(|| anyhow!("Invalid weights column"))?;
485 let weight_scale_col = weight_scale_column(&batch);
486
487 for i in 0..batch.num_rows() {
488 let term = term_col.value(i);
489 let Some(&qw) = query_weights.get(&term) else {
490 continue;
491 };
492 if vids_col.is_null(i) || weights_col.is_null(i) {
493 continue;
494 }
495 let vids_arr = vids_col.value(i);
496 let vids = vids_arr
497 .as_any()
498 .downcast_ref::<UInt64Array>()
499 .ok_or_else(|| anyhow!("Invalid inner vids type"))?;
500 let weights_arr = weights_col.value(i);
501 let weights =
502 term_weights(weights_arr.as_ref(), weight_scale_col.map(|c| c.value(i)))?;
503
504 for j in 0..vids.len() {
505 if vids.is_null(j) {
506 continue;
507 }
508 *scores.entry(vids.value(j)).or_insert(0.0) += qw * weights.get(j);
509 }
510 }
511 }
512
513 Ok(Self::top_k_from_scores(scores, k))
514 }
515
516 fn top_k_from_scores(scores: HashMap<u64, f32>, k: usize) -> Vec<(Vid, f32)> {
519 let mut heap: BinaryHeap<Reverse<HeapEntry>> = BinaryHeap::with_capacity(k + 1);
521 for (vid, score) in scores {
522 heap.push(Reverse(HeapEntry { score, vid }));
523 if heap.len() > k {
524 heap.pop();
525 }
526 }
527 let mut out: Vec<(Vid, f32)> = heap
528 .into_iter()
529 .map(|Reverse(e)| (Vid::from(e.vid), e.score))
530 .collect();
531 out.sort_by(|a, b| {
532 b.1.partial_cmp(&a.1)
533 .unwrap_or(std::cmp::Ordering::Equal)
534 .then(a.0.as_u64().cmp(&b.0.as_u64()))
535 });
536 out
537 }
538
539 #[instrument(skip(self), level = "debug")]
541 async fn load_postings(&self) -> Result<Postings> {
542 let Some(ds) = &self.dataset else {
543 return Ok(HashMap::new());
544 };
545 let mut postings: Postings = HashMap::new();
546 let scanner = ds.scan();
547 let mut stream = scanner.try_into_stream().await?;
548 while let Some(batch) = stream.try_next().await? {
549 let term_col = batch
550 .column_by_name("term_id")
551 .ok_or_else(|| anyhow!("Missing term_id column"))?
552 .as_any()
553 .downcast_ref::<UInt32Array>()
554 .ok_or_else(|| anyhow!("Invalid term_id column"))?;
555 let vids_col = batch
556 .column_by_name("vids")
557 .ok_or_else(|| anyhow!("Missing vids column"))?
558 .as_any()
559 .downcast_ref::<ListArray>()
560 .ok_or_else(|| anyhow!("Invalid vids column"))?;
561 let weights_col = batch
562 .column_by_name("weights")
563 .ok_or_else(|| anyhow!("Missing weights column"))?
564 .as_any()
565 .downcast_ref::<ListArray>()
566 .ok_or_else(|| anyhow!("Invalid weights column"))?;
567 let weight_scale_col = weight_scale_column(&batch);
568
569 for i in 0..batch.num_rows() {
570 if vids_col.is_null(i) || weights_col.is_null(i) {
571 continue;
572 }
573 let term = term_col.value(i);
574 let vids_arr = vids_col.value(i);
575 let vids = vids_arr
576 .as_any()
577 .downcast_ref::<UInt64Array>()
578 .ok_or_else(|| anyhow!("Invalid inner vids type"))?;
579 let weights_arr = weights_col.value(i);
580 let weights =
583 term_weights(weights_arr.as_ref(), weight_scale_col.map(|c| c.value(i)))?;
584 let entry = postings.entry(term).or_default();
585 for j in 0..vids.len() {
586 if !vids.is_null(j) {
587 entry.push((vids.value(j), weights.get(j)));
588 }
589 }
590 }
591 }
592 Ok(postings)
593 }
594
595 #[instrument(skip(self, added, removed), level = "info", fields(
599 label = %self.label,
600 property = %self.property,
601 added_count = added.len(),
602 removed_count = removed.len()
603 ))]
604 pub async fn apply_incremental_updates(
605 &mut self,
606 added: &HashMap<Vid, Vec<(u32, f32)>>,
607 removed: &HashSet<Vid>,
608 ) -> Result<()> {
609 let mut postings = self.load_postings().await?;
610
611 if !removed.is_empty() {
612 let removed_u64: HashSet<u64> = removed.iter().map(|v| v.as_u64()).collect();
613 for entries in postings.values_mut() {
614 entries.retain(|(vid, _)| !removed_u64.contains(vid));
615 }
616 postings.retain(|_, entries| !entries.is_empty());
617 }
618
619 for (vid, terms) in added {
620 let vid_u64 = vid.as_u64();
621 for &(term, weight) in terms {
622 postings.entry(term).or_default().push((vid_u64, weight));
623 }
624 }
625
626 self.write_postings(postings).await?;
627 Ok(())
628 }
629
630 pub fn is_initialized(&self) -> bool {
632 self.dataset.is_some()
633 }
634
635 pub fn property(&self) -> &str {
637 &self.property
638 }
639}
640
641struct HeapEntry {
643 score: f32,
644 vid: u64,
645}
646
647impl PartialEq for HeapEntry {
648 fn eq(&self, other: &Self) -> bool {
649 self.cmp(other) == std::cmp::Ordering::Equal
650 }
651}
652impl Eq for HeapEntry {}
653impl PartialOrd for HeapEntry {
654 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
655 Some(self.cmp(other))
656 }
657}
658impl Ord for HeapEntry {
659 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
660 self.score
661 .partial_cmp(&other.score)
662 .unwrap_or(std::cmp::Ordering::Equal)
663 .then(self.vid.cmp(&other.vid))
664 }
665}
666
667#[cfg(test)]
668mod tests {
669 use super::*;
670
671 #[test]
672 fn test_merge_postings_segments_overlapping() {
673 let seg1: Postings = [(1u32, vec![(10u64, 1.0f32)]), (2, vec![(11, 2.0)])]
674 .into_iter()
675 .collect();
676 let seg2: Postings = [(1u32, vec![(12u64, 3.0f32)]), (3, vec![(13, 4.0)])]
677 .into_iter()
678 .collect();
679 let merged = merge_postings_segments(vec![seg1, seg2]);
680 assert_eq!(merged.get(&1).unwrap().len(), 2);
681 assert_eq!(merged.get(&2).unwrap(), &vec![(11, 2.0)]);
682 assert_eq!(merged.get(&3).unwrap(), &vec![(13, 4.0)]);
683 }
684
685 #[test]
686 fn test_top_k_from_scores_orders_desc_and_caps() {
687 let scores: HashMap<u64, f32> = [(1u64, 0.5f32), (2, 3.0), (3, 1.0), (4, 2.0)]
688 .into_iter()
689 .collect();
690 let top = SparseVectorIndex::top_k_from_scores(scores, 2);
691 assert_eq!(top.len(), 2);
692 assert_eq!(top[0].0.as_u64(), 2);
693 assert_eq!(top[0].1, 3.0);
694 assert_eq!(top[1].0.as_u64(), 4);
695 assert_eq!(top[1].1, 2.0);
696 }
697
698 #[test]
699 fn test_top_k_tie_break_by_vid() {
700 let scores: HashMap<u64, f32> = [(7u64, 1.0f32), (3, 1.0)].into_iter().collect();
701 let top = SparseVectorIndex::top_k_from_scores(scores, 2);
702 assert_eq!(top[0].0.as_u64(), 3);
704 assert_eq!(top[1].0.as_u64(), 7);
705 }
706
707 #[test]
708 fn test_top_k_empty() {
709 assert!(SparseVectorIndex::top_k_from_scores(HashMap::new(), 5).is_empty());
710 }
711
712 #[test]
713 fn test_quantize_all_zero_term_no_nan() {
714 let (codes, scale, max_impact) = quantize_term(&[0.0, 0.0, 0.0]);
715 assert_eq!(codes, vec![0, 0, 0]);
716 assert_eq!(scale, 0.0);
717 assert_eq!(max_impact, 0.0);
718 assert!(!scale.is_nan() && !max_impact.is_nan());
719 }
720
721 #[test]
722 fn test_quantize_negative_weights_clamp_to_zero() {
723 let (codes, _scale, max_impact) = quantize_term(&[-1.0, -0.5]);
725 assert_eq!(codes, vec![0, 0]);
726 assert_eq!(max_impact, 0.0);
727 }
728
729 #[test]
730 fn test_quantize_max_weight_maps_to_top_code() {
731 let (codes, scale, max_impact) = quantize_term(&[0.1, 2.0, 1.0]);
732 assert_eq!(codes[1], 255);
734 for (j, &w) in [0.1f32, 2.0, 1.0].iter().enumerate() {
737 assert!(dequantize(codes[j], scale) <= max_impact + f32::EPSILON);
738 assert!((dequantize(codes[j], scale) - w).abs() <= scale / 2.0 + 1e-6);
740 }
741 }
742
743 proptest::proptest! {
744 #[test]
745 fn prop_quantize_roundtrip_and_bound(
746 weights in proptest::collection::vec(0.0f32..1000.0, 1..64)
747 ) {
748 let (codes, scale, max_impact) = quantize_term(&weights);
749 proptest::prop_assert_eq!(codes.len(), weights.len());
750 for (j, &w) in weights.iter().enumerate() {
751 let dq = dequantize(codes[j], scale);
752 proptest::prop_assert!(dq <= max_impact + 1e-4);
754 proptest::prop_assert!((dq - w).abs() <= scale / 2.0 + 1e-3);
756 proptest::prop_assert!(dq.is_finite());
757 }
758 }
759 }
760}