[AI] use self_cell to make posting list evaluation lazy

prompt:

These recent changes precollect all bytes that can be used during the
query before actually running the
  query. This is fine, and desired, but the PR takes it a step further
and precollects the evaluation of
  the data. This can potentially be expensive since we need to process
the entirety of the data.

  It is done like this so that lifetime tracking gets easier. But I have
a challenge for you. Can you make
  it so that the evaluation is preserved lazily? Meaning, to keep using
iterators as outputs.
This commit is contained in:
Luis Cossío
2026-04-20 17:30:19 -04:00
committed by Cursor Agent
parent 257d73bcce
commit 6dbebfa60a
4 changed files with 183 additions and 128 deletions

View File

@@ -0,0 +1,41 @@
use common::types::PointOffsetType;
use self_cell::self_cell;
use crate::index::field_index::full_text_index::inverted_index::TokenId;
use crate::index::field_index::full_text_index::inverted_index::mmap_inverted_index::raw_posting_list::RawPostingList;
// Alias is required by `self_cell`: the dependent's lifetime parameter must be
// expressed via a single-lifetime alias.
type BoxedPointOffsetIter<'a> = Box<dyn Iterator<Item = PointOffsetType> + 'a>;
self_cell! {
/// Lazy iterator over `PointOffsetType` that keeps the underlying raw
/// posting list bytes alive. The owner is a `Vec<(TokenId, RawPostingList)>`
/// produced by `UniversalPostings::fetch_*`, and the dependent is a boxed
/// iterator built on top of views into that vector.
///
/// Construction happens up-front (I/O is done in `fetch_*`), but the
/// actual query evaluation — posting-list traversal, intersection, phrase
/// matching — is driven on-demand from `next()`.
pub struct LazyPostingsIter<'a> {
owner: Vec<(TokenId, RawPostingList<'a>)>,
#[covariant]
dependent: BoxedPointOffsetIter,
}
}
impl Iterator for LazyPostingsIter<'_> {
type Item = PointOffsetType;
fn next(&mut self) -> Option<Self::Item> {
self.with_dependent_mut(|_owner, dependent| dependent.next())
}
}
impl<'a> LazyPostingsIter<'a> {
/// A `LazyPostingsIter` that yields nothing. Used when the query cannot
/// possibly match (empty posting set, missing tokens, etc.).
pub fn empty() -> Self {
Self::new(Vec::new(), |_| Box::new(std::iter::empty()))
}
}

View File

@@ -7,6 +7,7 @@ use common::mmap::{self, Advice, AdviceSetting, MmapSlice, create_and_ensure_len
use common::mmap_hashmap::{MmapHashMap, READ_ENTRY_OVERHEAD};
use common::types::PointOffsetType;
use common::universal_io::{MmapFile, OpenOptions};
use itertools::Itertools;
use types::ZerocopyPostingValue;
use uio_postings::UniversalPostings;
@@ -29,11 +30,14 @@ use crate::index::field_index::full_text_index::inverted_index::postings_iterato
};
mod create_postings;
mod lazy_iter;
pub mod mmap_postings_enum;
mod raw_posting_list;
pub(in crate::index::field_index::full_text_index) mod types;
mod uio_postings;
use self::lazy_iter::LazyPostingsIter;
const POSTINGS_FILE: &str = "postings.dat";
const VOCAB_FILE: &str = "vocab.dat";
const POINT_TO_TOKENS_COUNT_FILE: &str = "point_to_tokens_count.dat";
@@ -202,60 +206,68 @@ impl MmapInvertedIndex {
!is_deleted
}
/// Iterate over point ids whose documents contain all given tokens.
/// Iterate lazily over point ids whose documents contain **all** given tokens.
///
/// Pre-collected upfront because [`UniversalPostings`] exposes posting
/// views via a `FnOnce` callback. Acceptable since this index lives
/// on disk.
pub fn filter_has_all(&self, tokens: TokenSet) -> OperationResult<Vec<PointOffsetType>> {
/// I/O (the header reads and posting-list byte reads) is performed eagerly
/// inside `fetch_all_or_none`, so the returned iterator does not issue new
/// reads. The actual intersection work is performed on-demand as the
/// caller pulls items.
pub fn filter_has_all(&self, tokens: TokenSet) -> OperationResult<LazyPostingsIter<'_>> {
// in case of mmap immutable index, deleted points are still in the postings
let filter = move |idx| self.is_active(idx);
let is_active = move |idx| self.is_active(idx);
fn intersection<V: ZerocopyPostingValue>(
postings: &UniversalPostings<V, MmapFile>,
fn intersection<'a, V: ZerocopyPostingValue>(
postings: &'a UniversalPostings<V, MmapFile>,
tokens: TokenSet,
filter: impl Fn(PointOffsetType) -> bool,
) -> OperationResult<Vec<PointOffsetType>> {
let result =
postings.with_all_or_none_postings(tokens.tokens(), |posting_readers| {
if posting_readers.is_empty() {
return Ok(Vec::new());
}
let posting_readers = posting_readers
.into_iter()
.map(|(_token_id, posting_list_view)| posting_list_view)
.collect();
Ok(intersect_compressed_postings_iterator(posting_readers, filter).collect())
})?;
// Some token has no posting list -> no matches
Ok(result.unwrap_or_default())
is_active: impl Fn(PointOffsetType) -> bool + 'a,
) -> OperationResult<LazyPostingsIter<'a>> {
let Some(raw_postings) = postings.fetch_all_or_none(tokens.tokens())? else {
return Ok(LazyPostingsIter::empty());
};
if raw_postings.is_empty() {
return Ok(LazyPostingsIter::empty());
}
LazyPostingsIter::try_new(raw_postings, move |raw_postings| {
let views = raw_postings
.iter()
.map(|(_token_id, raw)| raw.as_view::<V>())
.try_collect()?;
Ok(Box::new(intersect_compressed_postings_iterator(
views, is_active,
)))
})
}
match &self.storage.postings {
MmapPostingsEnum::Ids(postings) => intersection(postings, tokens, filter),
MmapPostingsEnum::WithPositions(postings) => intersection(postings, tokens, filter),
MmapPostingsEnum::Ids(postings) => intersection(postings, tokens, is_active),
MmapPostingsEnum::WithPositions(postings) => intersection(postings, tokens, is_active),
}
}
/// Iterate over point ids whose documents contain at least one of the given tokens
fn filter_has_any(&self, tokens: TokenSet) -> OperationResult<Vec<PointOffsetType>> {
/// Iterate lazily over point ids whose documents contain **at least one** of the given tokens.
fn filter_has_any(&self, tokens: TokenSet) -> OperationResult<LazyPostingsIter<'_>> {
// in case of immutable index, deleted documents are still in the postings
let is_active = move |idx| self.is_active(idx);
fn merge<V: ZerocopyPostingValue>(
postings: &UniversalPostings<V, MmapFile>,
fn merge<'a, V: ZerocopyPostingValue>(
postings: &'a UniversalPostings<V, MmapFile>,
tokens: TokenSet,
is_active: impl Fn(PointOffsetType) -> bool,
) -> OperationResult<Vec<PointOffsetType>> {
postings.with_existing_postings(tokens.tokens(), |posting_readers| {
if posting_readers.is_empty() {
return Ok(Vec::new());
}
let posting_readers = posting_readers
.into_iter()
.map(|(_token_id, posting_list_view)| posting_list_view)
.collect();
Ok(merge_compressed_postings_iterator(posting_readers, is_active).collect())
is_active: impl Fn(PointOffsetType) -> bool + 'a,
) -> OperationResult<LazyPostingsIter<'a>> {
let raw_postings = postings.fetch_existing(tokens.tokens())?;
if raw_postings.is_empty() {
return Ok(LazyPostingsIter::empty());
}
LazyPostingsIter::try_new(raw_postings, move |raw_postings| {
let views = raw_postings
.iter()
.map(|(_token_id, raw)| raw.as_view::<V>())
.try_collect()?;
Ok(Box::new(merge_compressed_postings_iterator(
views, is_active,
)))
})
}
@@ -285,13 +297,15 @@ impl MmapInvertedIndex {
tokens: &TokenSet,
point_id: PointOffsetType,
) -> OperationResult<bool> {
let result = postings.with_all_or_none_postings(tokens.tokens(), |all_postings| {
Ok(all_postings
.into_iter()
.all(|(_token_id, posting)| posting.visitor().contains(point_id)))
})?;
// Some token has no posting list -> no match
Ok(result.unwrap_or(false))
let Some(raw_postings) = postings.fetch_all_or_none(tokens.tokens())? else {
return Ok(false);
};
for (_token_id, raw) in &raw_postings {
if !raw.as_view::<V>()?.visitor().contains(point_id) {
return Ok(false);
}
}
Ok(true)
}
match &self.storage.postings {
@@ -317,11 +331,13 @@ impl MmapInvertedIndex {
tokens: &TokenSet,
point_id: PointOffsetType,
) -> OperationResult<bool> {
postings.with_existing_postings(tokens.tokens(), |all_postings| {
Ok(all_postings
.into_iter()
.any(|(_token_id, posting)| posting.visitor().contains(point_id)))
})
let raw_postings = postings.fetch_existing(tokens.tokens())?;
for (_token_id, raw) in &raw_postings {
if raw.as_view::<V>()?.visitor().contains(point_id) {
return Ok(true);
}
}
Ok(false)
}
match &self.storage.postings {
@@ -330,8 +346,9 @@ impl MmapInvertedIndex {
}
}
/// Iterate over point ids whose documents contain all given tokens in the same order they are provided
pub fn filter_has_phrase(&self, phrase: Document) -> OperationResult<Vec<PointOffsetType>> {
/// Iterate lazily over point ids whose documents contain all given tokens
/// in the same order they are provided.
pub fn filter_has_phrase(&self, phrase: Document) -> OperationResult<LazyPostingsIter<'_>> {
// in case of mmap immutable index, deleted points are still in the postings
let is_active = move |idx| self.is_active(idx);
@@ -341,22 +358,27 @@ impl MmapInvertedIndex {
// not fetch the same posting list twice, otherwise positions get
// added twice in `phrase_in_all_postings`.
let unique_tokens = phrase.to_token_set();
let result = postings.with_all_or_none_postings(
unique_tokens.tokens(),
|selected_postings| {
Ok(intersect_compressed_postings_phrase_iterator(
phrase,
selected_postings,
is_active,
)
.collect())
},
)?;
// Some token has no posting list -> no matches
Ok(result.unwrap_or_default())
let Some(raw_postings) = postings.fetch_all_or_none(unique_tokens.tokens())? else {
return Ok(LazyPostingsIter::empty());
};
if raw_postings.is_empty() {
return Ok(LazyPostingsIter::empty());
}
LazyPostingsIter::try_new(raw_postings, move |raw_postings| {
let views = raw_postings
.iter()
.map(|(token_id, raw)| {
raw.as_view::<Positions>().map(|view| (*token_id, view))
})
.try_collect()?;
Ok(Box::new(intersect_compressed_postings_phrase_iterator(
phrase, views, is_active,
)))
})
}
// cannot do phrase matching if there's no positional information
MmapPostingsEnum::Ids(_postings) => Ok(Vec::new()),
MmapPostingsEnum::Ids(_postings) => Ok(LazyPostingsIter::empty()),
}
}
@@ -373,18 +395,19 @@ impl MmapInvertedIndex {
match &self.storage.postings {
MmapPostingsEnum::WithPositions(postings) => {
let unique_tokens = phrase.to_token_set();
let result = postings.with_all_or_none_postings(
unique_tokens.tokens(),
|selected_postings| {
Ok(check_compressed_postings_phrase(
phrase,
point_id,
selected_postings,
))
},
)?;
// Some token has no posting list -> no match
Ok(result.unwrap_or(false))
let Some(raw_postings) = postings.fetch_all_or_none(unique_tokens.tokens())? else {
return Ok(false);
};
let views = {
let raw_postings = &raw_postings;
raw_postings
.iter()
.map(|(token_id, raw)| {
raw.as_view::<Positions>().map(|view| (*token_id, view))
})
.try_collect()
}?;
Ok(check_compressed_postings_phrase(phrase, point_id, views))
}
// cannot do phrase matching if there's no positional information
MmapPostingsEnum::Ids(_postings) => Ok(false),
@@ -496,12 +519,11 @@ impl InvertedIndex for MmapInvertedIndex {
query: ParsedQuery,
_hw_counter: &HardwareCounterCell,
) -> OperationResult<Box<dyn Iterator<Item = PointOffsetType> + 'a>> {
let ids = match query {
ParsedQuery::AllTokens(tokens) => self.filter_has_all(tokens)?,
ParsedQuery::Phrase(phrase) => self.filter_has_phrase(phrase)?,
ParsedQuery::AnyTokens(tokens) => self.filter_has_any(tokens)?,
};
Ok(Box::new(ids.into_iter()))
match query {
ParsedQuery::AllTokens(tokens) => Ok(Box::new(self.filter_has_all(tokens)?)),
ParsedQuery::Phrase(phrase) => Ok(Box::new(self.filter_has_phrase(phrase)?)),
ParsedQuery::AnyTokens(tokens) => Ok(Box::new(self.filter_has_any(tokens)?)),
}
}
fn get_posting_len(

View File

@@ -21,8 +21,12 @@ impl<'a> RawPostingList<'a> {
}
}
impl<'a> RawPostingList<'a> {
pub fn as_view<V: ZerocopyPostingValue>(&'a self) -> OperationResult<PostingListView<'a, V>> {
impl RawPostingList<'_> {
/// Decode the raw bytes into a [`PostingListView`]. The view borrows from
/// `self` (not from the original `'a`), which allows it to be produced from
/// contexts that hold `RawPostingList` by value (e.g., inside a `self_cell`
/// builder where the outer `'a` is not reachable).
pub fn as_view<V: ZerocopyPostingValue>(&self) -> OperationResult<PostingListView<'_, V>> {
let (last_doc_id, bytes) = PointOffsetType::read_from_prefix(self.bytes.as_ref())?;
let (chunks, bytes) = <[PostingChunk<SizedTypeFor<V>>]>::ref_from_prefix_with_elems(

View File

@@ -4,7 +4,7 @@ use std::path::PathBuf;
use common::generic_consts::{Random, Sequential};
use common::universal_io::{OpenOptions, ReadRange, UniversalIoError, UniversalRead};
use posting_list::{PostingList, PostingListView};
use posting_list::PostingList;
use zerocopy::FromBytes;
use crate::common::operation_error::OperationResult;
@@ -172,15 +172,14 @@ impl<V: ZerocopyPostingValue, S: UniversalRead<u8>> UniversalPostings<V, S> {
}
/// Read the posting lists for every header yielded by `header_iter` and
/// hand the resulting views to `callback`. Header iteration is pipelined
/// into the posting reads - submissions are scheduled lazily as headers
/// arrive.
fn with_posting_views<T>(
&self,
header_iter: Box<dyn Iterator<Item = HeaderResult> + '_>,
/// return them as `Vec<(TokenId, RawPostingList)>`. Header iteration is
/// pipelined into the posting reads submissions are scheduled lazily as
/// headers arrive.
fn fetch_raw_postings<'a>(
&'a self,
header_iter: Box<dyn Iterator<Item = HeaderResult> + 'a>,
expected_capacity: usize,
callback: impl FnOnce(Vec<(TokenId, PostingListView<'_, V>)>) -> OperationResult<T>,
) -> OperationResult<T> {
) -> OperationResult<Vec<(TokenId, RawPostingList<'a>)>> {
let header_err: Cell<Option<UniversalIoError>> = Cell::new(None);
let range_iter = header_iter.filter_map(|header_res| match header_res {
@@ -197,7 +196,7 @@ impl<V: ZerocopyPostingValue, S: UniversalRead<u8>> UniversalPostings<V, S> {
}
});
let mut raw_postings: Vec<(TokenId, RawPostingList<'_>)> =
let mut raw_postings: Vec<(TokenId, RawPostingList<'a>)> =
Vec::with_capacity(expected_capacity);
for entry in self.storage.read_iter::<Sequential, _>(range_iter)? {
@@ -209,21 +208,16 @@ impl<V: ZerocopyPostingValue, S: UniversalRead<u8>> UniversalPostings<V, S> {
return Err(err.into());
}
let views = raw_postings
.iter()
.map(|(token_id, raw)| raw.as_view::<V>().map(|view| (*token_id, view)))
.collect::<OperationResult<Vec<_>>>()?;
callback(views)
Ok(raw_postings)
}
/// Fetch posting lists for the given token ids and hand them to `callback`.
/// If any of posting lists is not found - return `None`.
pub fn with_all_or_none_postings<T>(
&self,
/// Fetch the raw bytes of posting lists for the given token ids.
/// Returns `None` if any posting list is missing; otherwise returns
/// the full set in the order they were read from storage.
pub fn fetch_all_or_none<'a>(
&'a self,
token_ids: &[TokenId],
callback: impl FnOnce(Vec<(TokenId, PostingListView<'_, V>)>) -> OperationResult<T>,
) -> OperationResult<Option<T>> {
) -> OperationResult<Option<Vec<(TokenId, RawPostingList<'a>)>>> {
let HeadersBatch {
iter: header_iter,
missing,
@@ -233,36 +227,30 @@ impl<V: ZerocopyPostingValue, S: UniversalRead<u8>> UniversalPostings<V, S> {
return Ok(None);
}
self.with_posting_views(header_iter, token_ids.len(), callback)
self.fetch_raw_postings(header_iter, token_ids.len())
.map(Some)
}
/// Fetch all existing posting lists for the given token ids and hand them
/// to `callback`. Token ids without a posting list are silently ignored.
pub fn with_existing_postings<T>(
&self,
/// Fetch the raw bytes of every existing posting list among `token_ids`.
/// Token ids without a posting list are silently ignored.
pub fn fetch_existing<'a>(
&'a self,
token_ids: &[TokenId],
callback: impl FnOnce(Vec<(TokenId, PostingListView<'_, V>)>) -> OperationResult<T>,
) -> OperationResult<T> {
) -> OperationResult<Vec<(TokenId, RawPostingList<'a>)>> {
let HeadersBatch {
iter: header_iter, ..
} = self.headers_iter(token_ids)?;
self.with_posting_views(header_iter, token_ids.len(), callback)
self.fetch_raw_postings(header_iter, token_ids.len())
}
pub fn all_postings(&self) -> OperationResult<Vec<PostingList<V>>> {
let mut result = Vec::new();
let all_tokens = (0..self.header.posting_count as TokenId).collect::<Vec<_>>();
let raw_postings = self.fetch_existing(&all_tokens)?;
self.with_existing_postings(&all_tokens, |views| {
for (_, view) in views {
let posting_list = view.to_owned();
result.push(posting_list);
}
Ok(())
})?;
Ok(result)
raw_postings
.iter()
.map(|(_token_id, raw)| raw.as_view::<V>().map(|view| view.to_owned()))
.collect()
}
}