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}