Skip to main content

yo_doc/
vector.rs

1//! A vector index over a path into a document (`10` section 3).
2//!
3//! An embedding is a field like any other. A document that carries one at
4//! `$.embedding` should be one write, and a nearest neighbour search over the
5//! collection should be one call that hands back documents, not ids to go and
6//! look up somewhere else. Every other engine makes this two stores joined on
7//! the id by the caller, and the join is where the two of them drift apart.
8//!
9//! ```
10//! use yo_doc::{Builder, Docs, Key};
11//!
12//! let mut docs = Docs::new();
13//! docs.create_index("$.lang")?;
14//! docs.create_vector_index("$.embedding", 3)?;
15//!
16//! for (id, lang, v) in [
17//!     ("a", "en", [1.0, 0.0, 0.0]),
18//!     ("b", "fr", [0.9, 0.1, 0.0]),
19//!     ("c", "en", [0.0, 0.0, 1.0]),
20//! ] {
21//!     let mut b = Builder::new();
22//!     b.begin_object()?;
23//!     b.key(b"lang")?;
24//!     b.text(lang)?;
25//!     b.key(b"embedding")?;
26//!     b.begin_array()?;
27//!     for x in v {
28//!         b.float(x)?;
29//!     }
30//!     b.end_array()?;
31//!     b.end_object()?;
32//!     let bytes = b.finish()?.to_vec();
33//!     docs.put_bytes(id.as_bytes(), &bytes)?;
34//! }
35//!
36//! // Nearest overall, which is the French one.
37//! let mut best = Vec::new();
38//! docs.nearest("$.embedding", &[1.0, 0.05, 0.0], 2, |id, _, _| best.push(id.to_vec()))?;
39//! assert_eq!(best[0], b"a".to_vec());
40//! assert_eq!(best[1], b"b".to_vec());
41//!
42//! // Nearest among the English ones, decided inside the scan and not after it.
43//! let mut found = Vec::new();
44//! let english = [("$.lang", Key::text("en"))];
45//! docs.nearest_where("$.embedding", &[1.0, 0.05, 0.0], 2, &english, |id, _, _| {
46//!     found.push(id.to_vec())
47//! })?;
48//! assert_eq!(found, [b"a".to_vec(), b"c".to_vec()]);
49//! # Ok::<(), yo_common::Error>(())
50//! ```
51//!
52//! # Why this is not a [`PathIndex`](crate::PathIndex)
53//!
54//! Every other index kind files a document under byte keys, and the lookup is
55//! equality or a range over those bytes. Nearness is neither. There is no key a
56//! query could ask for, the answer depends on all of the coordinates at once,
57//! and the structure that answers it is a partitioned quantised index rather
58//! than a table from key to posting list. So a vector index is a
59//! [`Collection`], keyed by document id, held in its own list beside the path
60//! indexes rather than pretending to be one.
61//!
62//! It is still the same [`Collection`] a vector set on the wire is, which is Y23
63//! again: a document's embedding and a `VADD` land in the same code, so a
64//! replace, a zero length vector and a dimension mismatch cannot be answered one
65//! way here and another way there.
66//!
67//! # The filter is the point
68//!
69//! "The five nearest documents where the language is English" is the question
70//! people actually have, and answering it by searching for fifty and then
71//! throwing away the ones that are not English is a lottery. The more selective
72//! the filter the worse the lottery, and it fails quietly: the answers that come
73//! back are real, the ones that should have been there were never ranked.
74//!
75//! So the filter runs inside the posting scan. Every document carries a 64 bit
76//! [`Signature`] over the keys its other indexes filed it under, which sits
77//! beside the code in the posting and costs one instruction to test on a word
78//! the scan has already loaded. [`nearest_where`](crate::Docs::nearest_where)
79//! builds the same signature out of the values the query requires, and a
80//! document is worth ranking when its bits cover the query's.
81//!
82//! Two values can land on the same bit, so the signature can let a document
83//! through that does not really match. It can never reject one that does, which
84//! is the direction that matters, and the caller's own predicate over the
85//! answers settles the rest.
86//!
87//! Because the tag summarises the other indexes, it goes stale when the set of
88//! indexes changes. Declaring or dropping an index therefore rewrites every tag,
89//! which is one store per document per vector index with no requantising, rather
90//! than leaving a filter that used to work quietly answering nothing.
91
92use yo_common::{Code, Error, Result};
93use yo_shape::Metric;
94use yo_vector::{Collection, Signature};
95
96use crate::head::Kind;
97use crate::read::Value;
98
99/// One path holding an embedding, and the collection its vectors live in.
100///
101/// The collection is keyed by document id, so an answer from it is a document id
102/// and the caller never sees a second numbering.
103#[derive(Debug)]
104pub struct VectorIndex {
105    path: Vec<u8>,
106    c: Collection,
107}
108
109impl VectorIndex {
110    /// An empty index over `path`, holding `dim` wide vectors measured by
111    /// `metric`.
112    pub(crate) fn new(path: &[u8], dim: usize, metric: Metric) -> Result<VectorIndex> {
113        Ok(VectorIndex {
114            path: path.to_vec(),
115            c: Collection::new(dim, metric)?,
116        })
117    }
118
119    /// The path the embedding is read from.
120    #[must_use]
121    pub fn path(&self) -> &[u8] {
122        &self.path
123    }
124
125    /// How many coordinates a vector here has.
126    #[must_use]
127    pub fn dim(&self) -> usize {
128        self.c.dim()
129    }
130
131    /// What nearness means here.
132    #[must_use]
133    pub fn metric(&self) -> Metric {
134        self.c.metric()
135    }
136
137    /// How many documents have a vector filed.
138    ///
139    /// A document with nothing at the path is not in here, so the difference
140    /// between this and the collection's length is how many documents the index
141    /// does not cover.
142    #[must_use]
143    pub fn len(&self) -> usize {
144        self.c.len()
145    }
146
147    /// Whether none do.
148    #[must_use]
149    pub fn is_empty(&self) -> bool {
150        self.c.is_empty()
151    }
152
153    /// What the index costs.
154    #[must_use]
155    pub fn memory_bytes(&self) -> usize {
156        self.c.memory_bytes()
157    }
158
159    /// The collection underneath, for a caller that wants the vector API rather
160    /// than the document one.
161    #[must_use]
162    pub fn collection(&self) -> &Collection {
163        &self.c
164    }
165
166    /// The same, to write through.
167    pub(crate) fn collection_mut(&mut self) -> &mut Collection {
168        &mut self.c
169    }
170
171    /// Start again with the same path, dimension and metric.
172    ///
173    /// A collection has no empty in place, because the quantiser's rotation and
174    /// the partition layout are the collection. Building a new one is what
175    /// emptying it means, and the dimension and metric were already checked when
176    /// the index was declared.
177    pub(crate) fn clear(&mut self) {
178        self.c = Collection::new(self.c.dim(), self.c.metric())
179            .expect("the dimension and the metric were accepted when the index was declared");
180    }
181}
182
183/// Read the vector at `at` into `into`.
184///
185/// An array of `dim` numbers and nothing else. A path that holds something of
186/// another shape fails the write rather than being skipped, because unlike a
187/// scalar index there is no reading of "the document does not have one here":
188/// the caller declared a path as an embedding and put something else there.
189pub(crate) fn coordinates(
190    at: Value<'_>,
191    dim: usize,
192    path: &[u8],
193    into: &mut Vec<f32>,
194) -> Result<()> {
195    let wrong = || {
196        Error::fmt(
197            Code::Invalid,
198            format_args!(
199                "a value at {} is not an array of {dim} numbers",
200                String::from_utf8_lossy(path)
201            ),
202        )
203    };
204    if at.kind() != Kind::Array || at.len() != dim {
205        return Err(wrong());
206    }
207    into.clear();
208    into.reserve(dim);
209    for elem in at.iter() {
210        match elem.kind() {
211            Kind::Int => into.push(elem.as_int().ok_or_else(wrong)? as f32),
212            Kind::Float => into.push(elem.as_float().ok_or_else(wrong)? as f32),
213            _ => return Err(wrong()),
214        }
215    }
216    Ok(())
217}
218
219/// The tag a document with these index key lists carries.
220///
221/// One bit per path and key pair, which is exactly what
222/// [`Docs::nearest_where`](crate::Docs::nearest_where) builds on the other side.
223/// A document with an array index files under several keys at one path and gets
224/// a bit for each, so a query asking for any one of them still covers it.
225pub(crate) fn tag_of<'a>(slots: impl Iterator<Item = (&'a [u8], &'a [u8])>) -> u64 {
226    let mut sig = Signature::default();
227    for (path, keys) in slots {
228        add_keys(&mut sig, path, keys);
229    }
230    sig.bits()
231}
232
233/// Set the bit for every key in one index's key list.
234///
235/// The one place a path and a key become a bit, so that the tag a write puts on
236/// a document, the tag a rebuild puts back, and the signature a query is filtered
237/// by cannot drift apart.
238pub(crate) fn add_keys(sig: &mut Signature, path: &[u8], keys: &[u8]) {
239    crate::index::each_key(keys, |key| sig.insert_bytes(path, key));
240}