Stop rewriting whole sparse posting lists on every upsert (#10682)

`PostingList::upsert` ends in `propagate_max_next_weight_to_the_left`, whose doc
comment states "If an entry has a weight larger than `max_next_weight`, the
propagation stops". It never stopped — the loop always walked the entire prefix.

Record ids ascend during an upload, so every insert lands at the end, walks the
whole list, and growing a posting list to length L costs O(L²). On the NeurIPS
2023 sparse base set (MS MARCO / SPLADE) the hottest dimension appears in ~66% of
documents, so at 1M points its posting list holds 660k elements and every new
point rewrites all of them.

Two changes to `PostingList`:

* restore the early exit — every entry satisfies the same recurrence the loop
  walks, `max_next_weight[i] = max(max_next_weight[i + 1], weight[i + 1])`, so
  once an entry already holds the value being written, every entry to its left is
  correct too;
* add an append fast path — when the incoming record id is past the last stored
  id, push directly instead of binary searching a list that can hold millions of
  elements.

Neither changes what is stored. An index grown by `upsert` stays identical to one
built by `InvertedIndexBuilder`, `max_next_weight` included, which is what a
segment reload depends on; searches return identical results.

Building an `InvertedIndexRam` one point at a time, SPLADE vectors:

    points    before        after
    100k      2,216/s       124,016/s
    1M        250/s         114,422/s

Uploading 1M of those points to a single node: 72.2s -> 23.7s with default
collection settings, and 839.8s -> 29.5s with `indexing_threshold: 0`, which
stops segments converting and is the documented way to speed up a bulk load.


Claude-Session: https://claude.ai/code/session_01HXjykBsMZaRNrP17o2fuEJ

Co-authored-by: Andrey Vasnetsov <andrey@qdrant.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Andrey Vasnetsov
2026-09-17 11:00:35 +02:00
committed by GitHub
co-authored by Andrey Vasnetsov Claude Opus 5
parent 3eefe44572
commit 901fa8e865
+107
View File
@@ -53,6 +53,23 @@ impl PostingList {
/// Worst case is adding a new element at the end of the list with a very large weight.
/// This forces to propagate it as potential max_next_weight to all the previous elements.
pub fn upsert(&mut self, posting_element: PostingElementEx) {
// Fast path: a record id past every id already stored. This is what a sequential
// upload does for every single point, and it avoids a binary search that walks
// `log2(len)` cache lines of a list that can be millions of elements long.
match self.elements.last() {
None => {
// `max_next_weight` of a lone element is already `DEFAULT_MAX_NEXT_WEIGHT`
self.elements.push(posting_element);
return;
}
Some(last) if last.record_id < posting_element.record_id => {
self.elements.push(posting_element);
self.propagate_max_next_weight_to_the_left(self.elements.len() - 1);
return;
}
Some(_) => {}
}
// find insertion point in sorted posting list (most expensive operation for large posting list)
let index = self
.elements
@@ -103,6 +120,13 @@ impl PostingList {
// propagate max_next_weight update to the previous entries
for element in self.elements[..up_to_index].iter_mut().rev() {
// Every entry holds `max_next_weight[i] = max(max_next_weight[i + 1], weight[i + 1])`,
// the same recurrence this loop walks. So once an entry already holds the value we
// are about to write, every entry further left is already correct too and the
// propagation can stop -- which is what the doc comment above always promised.
if element.max_next_weight == max_next_weight {
return;
}
// update max_next_weight for element
element.max_next_weight = max_next_weight;
max_next_weight = max_next_weight.max(element.weight);
@@ -549,4 +573,87 @@ mod tests {
);
}
}
/// Appending a weight that is already dominated by what follows an entry must leave
/// that entry — and everything before it — untouched. This is what lets
/// `propagate_max_next_weight_to_the_left` stop instead of walking the whole list on
/// every insert.
#[test]
fn test_upsert_append_stops_propagating_early() {
let mut builder = PostingBuilder::new();
builder.add(1, 1.0);
builder.add(2, 9.0);
builder.add(3, 2.0);
let mut posting_list = builder.build();
assert_eq!(posting_list.elements[0].max_next_weight, 9.0);
assert_eq!(posting_list.elements[1].max_next_weight, 2.0);
// 0.5 is smaller than the 2.0 already ahead of element 1, so only the entry that
// used to be last can change.
posting_list.upsert(PostingElementEx::new(4, 0.5));
assert_eq!(posting_list.elements[0].max_next_weight, 9.0);
assert_eq!(posting_list.elements[1].max_next_weight, 2.0);
assert_eq!(posting_list.elements[2].max_next_weight, 0.5);
assert_eq!(
posting_list.elements[3].max_next_weight,
DEFAULT_MAX_NEXT_WEIGHT
);
}
/// A posting list grown with `upsert` must be exactly what `PostingBuilder` produces
/// for the same elements, `max_next_weight` included — that equivalence is what the
/// early exit relies on, and it is also what a segment reload assumes, since loading
/// rebuilds the index through the builder while an upload grows it through `upsert`.
#[test]
fn test_upsert_matches_builder() {
use rand::RngExt;
use rand::seq::SliceRandom;
let mut rng = rand::rng();
for _ in 0..100 {
let mut ids = Vec::new();
let mut cur_id = 0;
for _ in 0..64 {
cur_id += rng.random_range(1..10);
ids.push(cur_id);
}
let weights: Vec<DimWeight> = (0..ids.len())
.map(|_| rng.random_range(0..100) as f32 / 10.0)
.collect();
let mut builder = PostingBuilder::new();
for (&id, &weight) in ids.iter().zip(&weights) {
builder.add(id, weight);
}
let expected = builder.build();
// ascending ids, which is what an upload does
let mut appended = PostingList::default();
for (&id, &weight) in ids.iter().zip(&weights) {
appended.upsert(PostingElementEx::new(id, weight));
}
assert_eq!(appended, expected);
// shuffled ids, so the inserts land in the middle of the list
let mut order = (0..ids.len()).collect_vec();
order.shuffle(&mut rng);
let mut shuffled = PostingList::default();
for &i in &order {
shuffled.upsert(PostingElementEx::new(ids[i], weights[i]));
}
assert_eq!(shuffled, expected);
// overwriting every weight in place must converge on the same list
let mut overwritten = PostingList::default();
for (&id, &weight) in ids.iter().zip(&weights) {
overwritten.upsert(PostingElementEx::new(id, weight + 1.0));
}
for &i in &order {
overwritten.upsert(PostingElementEx::new(ids[i], weights[i]));
}
assert_eq!(overwritten, expected);
}
}
}