Skip to main content

oxilite_core/
vector.rs

1//! Vector indexes over embeddings stored as RDF literals (backends with vector functions: Turso).
2//!
3//! An index is described in the system graph `<oxilite:vectors>` by the resource
4//! `<oxilite:vector/NAME>` (see [`VectorIndex::to_quads`]) — the definition is data, queryable
5//! and versioned like any other. It is realised as a table `vec_name` of `(s, o, g, e)` rows,
6//! one per embedding literal of the indexed property, kept current by triggers on `quads` in
7//! the same transaction as every write, and back-filled when it is created. A fingerprint in
8//! `oxilite_meta` (`vector:name`) records how the table was built, so [`sync_statements`] can
9//! tell a missing, stale or orphaned table from a current one.
10//!
11//! [`knn_sql`] is the one nearest-neighbour statement: the SPARQL compiler
12//! (`SERVICE <oxilite:vector/NAME>`), the Datalog built-in `nearest` and the store API all use it.
13//!
14// @lat: [[architecture#Vector indexes]]
15
16use crate::encoding::{named_node_id, rdf_type_id, DEFAULT_GRAPH_ID};
17use crate::error::{Error, Result};
18use crate::registry::NS;
19use crate::sql::{sql_str, Capabilities, Statement};
20use oxrdf::vocab::{rdf, xsd};
21use oxrdf::{GraphName, Literal, NamedNode, Quad, Term};
22use std::collections::BTreeMap;
23
24/// The system graph holding vector index definitions.
25pub const VECTORS_GRAPH: &str = "oxilite:vectors";
26
27/// The IRI prefix of an index: `<oxilite:vector/NAME>` names it, and is the `SERVICE` IRI that
28/// searches it.
29pub const INDEX_PREFIX: &str = "oxilite:vector/";
30
31/// The key prefix of build fingerprints in `oxilite_meta`.
32const META_PREFIX: &str = "vector:";
33
34/// Aborts a back-fill that meets a value of the wrong dimensions ("CHECK constraint failed:
35/// vector_dimensions_mismatch", see `Error::backend`).
36const GUARD_TABLE: &str = "CREATE TABLE IF NOT EXISTS oxilite_vector_guard (\
37    vector_dimensions_mismatch TEXT CHECK (vector_dimensions_mismatch IS NULL))";
38
39/// Most results a search may ask for.
40pub const MAX_K: u64 = 10_000;
41
42/// Default number of results.
43pub const DEFAULT_K: u64 = 10;
44
45/// IRIs of the vector vocabulary (in the `oxl:` namespace).
46pub mod vocab {
47    pub const VECTOR_INDEX: &str = "https://oxilite.dev/ns#VectorIndex";
48    pub const INDEX_NAME: &str = "https://oxilite.dev/ns#indexName";
49    pub const PROPERTY: &str = "https://oxilite.dev/ns#property";
50    pub const DIMENSIONS: &str = "https://oxilite.dev/ns#dimensions";
51    pub const METRIC: &str = "https://oxilite.dev/ns#metric";
52    pub const ELEMENT_TYPE: &str = "https://oxilite.dev/ns#elementType";
53    pub const CLASS: &str = "https://oxilite.dev/ns#class";
54    /// Search predicates, inside `SERVICE <oxilite:vector/NAME> { … }`.
55    pub const QUERY: &str = "https://oxilite.dev/ns#query";
56    pub const K: &str = "https://oxilite.dev/ns#k";
57    pub const NODE: &str = "https://oxilite.dev/ns#node";
58    pub const DISTANCE: &str = "https://oxilite.dev/ns#distance";
59    pub const SCORE: &str = "https://oxilite.dev/ns#score";
60}
61
62/// How distance is measured.
63#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
64pub enum Metric {
65    #[default]
66    Cosine,
67    Euclidean,
68    DotProduct,
69    /// Jaccard distance over sparse vectors (weighted: `1 - Σmin / Σmax`).
70    Jaccard,
71}
72
73impl Metric {
74    pub const ALL: [Self; 4] = [
75        Self::Cosine,
76        Self::Euclidean,
77        Self::DotProduct,
78        Self::Jaccard,
79    ];
80
81    /// The name used by Cypher, the shell and the studio.
82    pub fn name(self) -> &'static str {
83        match self {
84            Self::Cosine => "cosine",
85            Self::Euclidean => "euclidean",
86            Self::DotProduct => "dot",
87            Self::Jaccard => "jaccard",
88        }
89    }
90
91    pub fn iri(self) -> String {
92        format!(
93            "{NS}{}",
94            match self {
95                Self::Cosine => "Cosine",
96                Self::Euclidean => "Euclidean",
97                Self::DotProduct => "DotProduct",
98                Self::Jaccard => "Jaccard",
99            }
100        )
101    }
102
103    /// Parses a name (`cosine`, `euclidean`/`l2`, `dot`/`dot_product`, `jaccard`).
104    pub fn parse(s: &str) -> Option<Self> {
105        Some(match s.to_ascii_lowercase().as_str() {
106            "cosine" | "cos" => Self::Cosine,
107            "euclidean" | "l2" => Self::Euclidean,
108            "dot" | "dot_product" | "dotproduct" => Self::DotProduct,
109            "jaccard" => Self::Jaccard,
110            _ => return None,
111        })
112    }
113
114    pub fn from_iri(iri: &str) -> Option<Self> {
115        Self::ALL.into_iter().find(|m| m.iri() == iri)
116    }
117
118    /// The SQL distance function.
119    pub fn distance_fn(self) -> &'static str {
120        match self {
121            Self::Cosine => "vector_distance_cos",
122            Self::Euclidean => "vector_distance_l2",
123            Self::DotProduct => "vector_distance_dot",
124            Self::Jaccard => "vector_distance_jaccard",
125        }
126    }
127
128    /// The similarity score of a distance `d`, as Neo4j defines it: higher is nearer.
129    pub fn score_sql(self, d: &str) -> String {
130        match self {
131            Self::Cosine => format!("(1.0 - ({d}) / 2.0)"),
132            Self::Euclidean => format!("(1.0 / (1.0 + ({d}) * ({d})))"),
133            // Turso's dot distance is the negated dot product.
134            Self::DotProduct => format!("(-({d}))"),
135            Self::Jaccard => format!("(1.0 - ({d}))"),
136        }
137    }
138
139    /// [`Self::score_sql`] in Rust.
140    pub fn score(self, d: f64) -> f64 {
141        match self {
142            Self::Cosine => 1.0 - d / 2.0,
143            Self::Euclidean => 1.0 / (1.0 + d * d),
144            Self::DotProduct => -d,
145            Self::Jaccard => 1.0 - d,
146        }
147    }
148}
149
150/// How vector elements are stored.
151#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
152pub enum ElementType {
153    #[default]
154    Float32,
155    Float64,
156    /// Quantized to 8 bits.
157    Int8,
158    /// Quantized to one bit per dimension.
159    Bit1,
160    /// Sparse 32-bit floats (only the non-zero elements are stored).
161    SparseFloat32,
162}
163
164impl ElementType {
165    pub const ALL: [Self; 5] = [
166        Self::Float32,
167        Self::Float64,
168        Self::Int8,
169        Self::Bit1,
170        Self::SparseFloat32,
171    ];
172
173    pub fn name(self) -> &'static str {
174        match self {
175            Self::Float32 => "float32",
176            Self::Float64 => "float64",
177            Self::Int8 => "int8",
178            Self::Bit1 => "bit1",
179            Self::SparseFloat32 => "sparse",
180        }
181    }
182
183    pub fn iri(self) -> String {
184        format!(
185            "{NS}{}",
186            match self {
187                Self::Float32 => "Float32",
188                Self::Float64 => "Float64",
189                Self::Int8 => "Int8",
190                Self::Bit1 => "Bit1",
191                Self::SparseFloat32 => "SparseFloat32",
192            }
193        )
194    }
195
196    pub fn parse(s: &str) -> Option<Self> {
197        Some(match s.to_ascii_lowercase().as_str() {
198            "float32" | "f32" => Self::Float32,
199            "float64" | "f64" => Self::Float64,
200            "int8" | "i8" | "float8" => Self::Int8,
201            "bit1" | "1bit" | "binary" => Self::Bit1,
202            "sparse" | "sparse_float32" | "sparsefloat32" => Self::SparseFloat32,
203            _ => return None,
204        })
205    }
206
207    pub fn from_iri(iri: &str) -> Option<Self> {
208        Self::ALL.into_iter().find(|t| t.iri() == iri)
209    }
210
211    /// The SQL function turning a JSON array into a vector of this type.
212    pub fn convert_fn(self) -> &'static str {
213        match self {
214            Self::Float32 => "vector32",
215            Self::Float64 => "vector64",
216            Self::Int8 => "vector8",
217            Self::Bit1 => "vector1bit",
218            Self::SparseFloat32 => "vector32_sparse",
219        }
220    }
221}
222
223/// A vector index definition.
224#[derive(Debug, Clone, PartialEq, Eq)]
225pub struct VectorIndex {
226    pub name: String,
227    /// The property whose literal values are embeddings (`"[0.1, 0.2, …]"`).
228    pub property: NamedNode,
229    pub dimensions: u32,
230    pub metric: Metric,
231    pub element_type: ElementType,
232    /// Only instances of this class are candidates.
233    pub class: Option<NamedNode>,
234}
235
236impl VectorIndex {
237    /// A cosine, 32-bit float index.
238    pub fn new(name: impl Into<String>, property: NamedNode, dimensions: u32) -> Self {
239        Self {
240            name: name.into(),
241            property,
242            dimensions,
243            metric: Metric::Cosine,
244            element_type: ElementType::Float32,
245            class: None,
246        }
247    }
248
249    pub fn metric(mut self, metric: Metric) -> Self {
250        self.metric = metric;
251        self
252    }
253
254    pub fn element_type(mut self, element_type: ElementType) -> Self {
255        self.element_type = element_type;
256        self
257    }
258
259    pub fn class(mut self, class: NamedNode) -> Self {
260        self.class = Some(class);
261        self
262    }
263
264    /// Checks the name, the dimensions and the metric/element-type combination.
265    pub fn validate(&self) -> Result<()> {
266        let mut chars = self.name.chars();
267        let ok = self.name.len() <= 64
268            && chars.next().is_some_and(|c| c.is_ascii_alphabetic())
269            && chars.all(|c| c.is_ascii_alphanumeric() || c == '_');
270        if !ok {
271            return Err(Error::Other(format!(
272                "vector index name {:?} must match [A-Za-z][A-Za-z0-9_]{{0,63}}",
273                self.name
274            )));
275        }
276        if self.dimensions == 0 || self.dimensions > 65_536 {
277            return Err(Error::Other(format!(
278                "vector index {}: dimensions must be between 1 and 65536, not {}",
279                self.name, self.dimensions
280            )));
281        }
282        let sparse = self.element_type == ElementType::SparseFloat32;
283        if (self.metric == Metric::Jaccard) != sparse {
284            return Err(Error::Other(format!(
285                "vector index {}: the Jaccard metric goes with sparse vectors, and sparse vectors with Jaccard",
286                self.name
287            )));
288        }
289        Ok(())
290    }
291
292    /// `<oxilite:vector/NAME>`.
293    pub fn iri(&self) -> NamedNode {
294        index_iri(&self.name)
295    }
296
297    /// The table holding the embeddings (lower case: SQLite names ignore case).
298    pub fn table(&self) -> String {
299        table_name(&self.name)
300    }
301
302    /// Everything the built table depends on.
303    pub fn fingerprint(&self) -> String {
304        format!(
305            "{}|{}|{}|{}|{}",
306            self.property.as_str(),
307            self.dimensions,
308            self.metric.name(),
309            self.element_type.name(),
310            self.class.as_ref().map_or("", NamedNode::as_str)
311        )
312    }
313
314    /// The definition as quads of `<oxilite:vectors>`.
315    pub fn to_quads(&self) -> Vec<Quad> {
316        let g = GraphName::NamedNode(NamedNode::new_unchecked(VECTORS_GRAPH));
317        let s = self.iri();
318        let n = |i: &str| NamedNode::new_unchecked(i);
319        let mut out = vec![
320            Quad::new(s.clone(), rdf::TYPE, n(vocab::VECTOR_INDEX), g.clone()),
321            Quad::new(
322                s.clone(),
323                n(vocab::INDEX_NAME),
324                Literal::new_simple_literal(&self.name),
325                g.clone(),
326            ),
327            Quad::new(
328                s.clone(),
329                n(vocab::PROPERTY),
330                self.property.clone(),
331                g.clone(),
332            ),
333            Quad::new(
334                s.clone(),
335                n(vocab::DIMENSIONS),
336                Literal::from(i64::from(self.dimensions)),
337                g.clone(),
338            ),
339            Quad::new(
340                s.clone(),
341                n(vocab::METRIC),
342                n(&self.metric.iri()),
343                g.clone(),
344            ),
345            Quad::new(
346                s.clone(),
347                n(vocab::ELEMENT_TYPE),
348                n(&self.element_type.iri()),
349                g.clone(),
350            ),
351        ];
352        if let Some(c) = &self.class {
353            out.push(Quad::new(s, n(vocab::CLASS), c.clone(), g));
354        }
355        out
356    }
357
358    /// Statements creating the table, its triggers (and IVF index), back-filling it and
359    /// recording its fingerprint. Run them in one atomic request: a malformed existing value
360    /// aborts the back-fill, and with it the whole creation.
361    pub fn create_statements(&self, caps: &Capabilities) -> Result<Vec<Statement>> {
362        require(caps)?;
363        self.validate()?;
364        let t = self.table();
365        let pid = named_node_id(self.property.as_str());
366        let dims = self.dimensions;
367        let conv = self.element_type.convert_fn();
368        let msg = sql_str(&format!(
369            "oxilite: vector index {} expects {dims} dimensions",
370            self.name
371        ));
372        let check = |o: &str| {
373            format!(
374                "COALESCE(json_array_length((SELECT lex FROM terms WHERE id = {o})), -1) <> {dims}"
375            )
376        };
377        let mut s = vec![
378            Statement::new(format!(
379                "CREATE TABLE IF NOT EXISTS {t} (s INTEGER NOT NULL, o INTEGER NOT NULL, g INTEGER NOT NULL, \
380                 e BLOB NOT NULL, PRIMARY KEY (s, o, g))"
381            )),
382            Statement::new(format!(
383                "CREATE TRIGGER IF NOT EXISTS {t}_ins AFTER INSERT ON quads WHEN NEW.p = {pid} BEGIN \
384                 SELECT RAISE(ABORT, {msg}) WHERE {}; \
385                 INSERT OR IGNORE INTO {t}(s, o, g, e) SELECT NEW.s, NEW.o, NEW.g, {conv}(lex) FROM terms WHERE id = NEW.o; \
386                 END",
387                check("NEW.o")
388            )),
389            Statement::new(format!(
390                "CREATE TRIGGER IF NOT EXISTS {t}_del AFTER DELETE ON quads WHEN OLD.p = {pid} BEGIN \
391                 DELETE FROM {t} WHERE s = OLD.s AND o = OLD.o AND g = OLD.g; END"
392            )),
393            // Back-fill: the check first, so bad data aborts before anything is copied (RAISE
394            // only exists in triggers; a CHECK constraint aborts a plain statement).
395            Statement::new(GUARD_TABLE),
396            Statement::new(format!(
397                "INSERT INTO oxilite_vector_guard(vector_dimensions_mismatch) \
398                 SELECT {} FROM quads q WHERE q.p = {pid} AND {} LIMIT 1",
399                sql_str(&self.name),
400                check("q.o")
401            )),
402            Statement::new(format!(
403                "INSERT OR IGNORE INTO {t}(s, o, g, e) SELECT q.s, q.o, q.g, {conv}(t.lex) \
404                 FROM quads q JOIN terms t ON t.id = q.o WHERE q.p = {pid}"
405            )),
406        ];
407        if self.element_type == ElementType::SparseFloat32 && caps.vector_index_methods {
408            s.push(Statement::new(format!(
409                "CREATE INDEX IF NOT EXISTS {t}_ivf ON {t} USING toy_vector_sparse_ivf (e)"
410            )));
411        }
412        s.push(Statement::new(format!(
413            "INSERT OR REPLACE INTO oxilite_meta(key, value) VALUES ({}, {})",
414            sql_str(&format!("{META_PREFIX}{}", self.name.to_lowercase())),
415            sql_str(&self.fingerprint())
416        )));
417        Ok(s)
418    }
419
420    /// The nearest-neighbour statement for this index (see [`knn_sql`]).
421    pub fn knn_sql(&self, query: &QueryVector, k: u64) -> Result<String> {
422        knn_sql(self, query, k)
423    }
424}
425
426/// `<oxilite:vector/NAME>`.
427pub fn index_iri(name: &str) -> NamedNode {
428    NamedNode::new_unchecked(format!("{INDEX_PREFIX}{name}"))
429}
430
431fn table_name(name: &str) -> String {
432    format!("vec_{}", name.to_lowercase())
433}
434
435fn require(caps: &Capabilities) -> Result<()> {
436    if caps.vectors {
437        Ok(())
438    } else {
439        Err(Error::unsupported(format!(
440            "vector indexes need a backend with vector functions, such as Turso (oxilite-turso); {} has none",
441            caps.name
442        )))
443    }
444}
445
446/// Statements dropping an index's table, triggers and fingerprint (by lower-case name).
447pub fn drop_statements(name: &str) -> Vec<Statement> {
448    let t = table_name(name);
449    vec![
450        Statement::new(format!("DROP TRIGGER IF EXISTS {t}_ins")),
451        Statement::new(format!("DROP TRIGGER IF EXISTS {t}_del")),
452        Statement::new(format!("DROP INDEX IF EXISTS {t}_ivf")),
453        Statement::new(format!("DROP TABLE IF EXISTS {t}")),
454        Statement::new(format!(
455            "DELETE FROM oxilite_meta WHERE key = {}",
456            sql_str(&format!("{META_PREFIX}{}", name.to_lowercase()))
457        )),
458    ]
459}
460
461/// What a search is near.
462#[derive(Debug, Clone, PartialEq)]
463pub enum QueryVector {
464    /// A vector, as the JSON array text the index stores (`"[0.1, 0.2]"`).
465    Vector(String),
466    /// A node: its stored embedding is the query.
467    Node(Term),
468}
469
470impl QueryVector {
471    /// A vector of numbers.
472    pub fn vector(values: &[f64]) -> Self {
473        Self::Vector(format!(
474            "[{}]",
475            values
476                .iter()
477                .map(|v| v.to_string())
478                .collect::<Vec<_>>()
479                .join(",")
480        ))
481    }
482
483    /// A node, whose stored embedding is used.
484    pub fn node(node: impl Into<Term>) -> Self {
485        Self::Node(node.into())
486    }
487}
488
489/// Parses a JSON array of numbers, the lexical form of an embedding.
490pub fn parse_vector(text: &str) -> Option<Vec<f64>> {
491    let inner = text.trim().strip_prefix('[')?.strip_suffix(']')?.trim();
492    if inner.is_empty() {
493        return Some(Vec::new());
494    }
495    inner
496        .split(',')
497        .map(|x| x.trim().parse::<f64>().ok().filter(|v| v.is_finite()))
498        .collect()
499}
500
501/// The nearest-neighbour statement: rows `(s, d)` — at most `k` distinct nodes (term ids), each
502/// at its smallest distance, by increasing distance then id.
503pub fn knn_sql(index: &VectorIndex, query: &QueryVector, k: u64) -> Result<String> {
504    if k == 0 || k > MAX_K {
505        return Err(Error::Other(format!(
506            "vector search: k must be between 1 and {MAX_K}, not {k}"
507        )));
508    }
509    let t = index.table();
510    let q = match query {
511        QueryVector::Vector(text) => {
512            let v = parse_vector(text).ok_or_else(|| {
513                Error::Other(format!(
514                    "vector search on {}: the query {text:?} is not a JSON array of numbers",
515                    index.name
516                ))
517            })?;
518            if v.len() != index.dimensions as usize {
519                return Err(Error::Other(format!(
520                    "vector search on {}: the query has {} dimensions, the index {}",
521                    index.name,
522                    v.len(),
523                    index.dimensions
524                )));
525            }
526            format!("{}({})", index.element_type.convert_fn(), sql_str(text))
527        }
528        QueryVector::Node(node) => format!(
529            "(SELECT e FROM {t} WHERE s = {} LIMIT 1)",
530            crate::encoding::term_id(node.as_ref())
531        ),
532    };
533    let dist = index.metric.distance_fn();
534    let inner = match &index.class {
535        None if index.element_type == ElementType::SparseFloat32 => {
536            // The shape the IVF index method recognises.
537            format!("SELECT s, {dist}(e, {q}) AS d FROM {t} ORDER BY d LIMIT {k}")
538        }
539        None => format!("SELECT v.s AS s, {dist}(v.e, {q}) AS d FROM {t} v"),
540        Some(class) => format!(
541            "SELECT v.s AS s, {dist}(v.e, {q}) AS d FROM {t} v WHERE EXISTS (SELECT 1 FROM quads c \
542             WHERE c.s = v.s AND c.p = {} AND c.o = {})",
543            rdf_type_id(),
544            named_node_id(class.as_str())
545        ),
546    };
547    Ok(format!(
548        "SELECT s, MIN(d) AS d FROM ({inner}) GROUP BY s ORDER BY d, s LIMIT {k}"
549    ))
550}
551
552/// [`knn_sql`] with a 1-based rank column `r` (ties broken by id), for relational frontends
553/// that carry term ids rather than computed values.
554pub fn knn_ranked_sql(index: &VectorIndex, query: &QueryVector, k: u64) -> Result<String> {
555    let knn = knn_sql(index, query, k)?;
556    Ok(format!(
557        "WITH knn AS ({knn}) SELECT knn.s AS s, knn.d AS d, \
558         (SELECT COUNT(*) FROM knn k2 WHERE k2.d < knn.d OR (k2.d = knn.d AND k2.s < knn.s)) + 1 AS r FROM knn"
559    ))
560}
561
562/// The statement reading the definitions: every quad of `<oxilite:vectors>` with the text of its
563/// terms (see [`definitions_from_rows`]).
564pub fn definitions_statement(id_col: impl Fn(&str) -> String) -> Statement {
565    Statement::new(format!(
566        "SELECT {}, {}, {}, ts.lex, tp.lex, t.lex, t.dt, t.lang, t.dir FROM quads q \
567         LEFT JOIN terms ts ON ts.id = q.s LEFT JOIN terms tp ON tp.id = q.p LEFT JOIN terms t ON t.id = q.o \
568         WHERE q.g = {}",
569        id_col("q.s"),
570        id_col("q.p"),
571        id_col("q.o"),
572        named_node_id(VECTORS_GRAPH)
573    ))
574}
575
576/// Definitions read from the rows of [`definitions_statement`]: the valid ones, and a message
577/// for each description that is not one.
578pub fn definitions_from_rows(
579    rows: &[Vec<crate::sql::SqlValue>],
580) -> (Vec<VectorIndex>, Vec<String>) {
581    let mut by_subject: BTreeMap<String, Vec<(String, Term)>> = BTreeMap::new();
582    for row in rows {
583        let get = |i: usize| row.get(i).cloned().unwrap_or(crate::sql::SqlValue::Null);
584        let (Some(_s), Some(_p), Some(o)) = (get(0).as_i64(), get(1).as_i64(), get(2).as_i64())
585        else {
586            continue;
587        };
588        let (Some(s), Some(p)) = (get(3).into_string(), get(4).into_string()) else {
589            continue;
590        };
591        let term = match crate::encoding::decode_inline(o) {
592            Some(t) => t,
593            None => {
594                let Some(lex) = get(5).into_string() else {
595                    continue;
596                };
597                match crate::encoding::decode_row(
598                    o,
599                    lex,
600                    get(6).into_string(),
601                    get(7).into_string(),
602                    get(8).as_i64(),
603                ) {
604                    Ok(t) => t,
605                    Err(_) => continue,
606                }
607            }
608        };
609        by_subject.entry(s).or_default().push((p, term));
610    }
611    definitions_from_triples(by_subject)
612}
613
614/// Definitions read from quads of `<oxilite:vectors>` (other quads are ignored).
615pub fn definitions_from_quads(quads: &[Quad]) -> (Vec<VectorIndex>, Vec<String>) {
616    let mut by_subject: BTreeMap<String, Vec<(String, Term)>> = BTreeMap::new();
617    for q in quads {
618        if !matches!(&q.graph_name, GraphName::NamedNode(g) if g.as_str() == VECTORS_GRAPH) {
619            continue;
620        }
621        if let oxrdf::NamedOrBlankNode::NamedNode(s) = &q.subject {
622            by_subject
623                .entry(s.as_str().to_owned())
624                .or_default()
625                .push((q.predicate.as_str().to_owned(), q.object.clone()));
626        }
627    }
628    definitions_from_triples(by_subject)
629}
630
631fn definitions_from_triples(
632    by_subject: BTreeMap<String, Vec<(String, Term)>>,
633) -> (Vec<VectorIndex>, Vec<String>) {
634    let mut defs: Vec<VectorIndex> = Vec::new();
635    let mut problems = Vec::new();
636    for (s, props) in by_subject {
637        let is_index = props.iter().any(|(p, o)| {
638            p == rdf::TYPE.as_str()
639                && matches!(o, Term::NamedNode(n) if n.as_str() == vocab::VECTOR_INDEX)
640        });
641        if !is_index {
642            continue;
643        }
644        match definition(&s, &props) {
645            Ok(d) => {
646                if let Some(other) = defs.iter().find(|x| x.table() == d.table()) {
647                    problems.push(format!(
648                        "<{s}>: index name {} clashes with {} (names are compared ignoring case)",
649                        d.name, other.name
650                    ));
651                } else {
652                    defs.push(d);
653                }
654            }
655            Err(e) => problems.push(format!("<{s}>: {e}")),
656        }
657    }
658    (defs, problems)
659}
660
661fn definition(s: &str, props: &[(String, Term)]) -> std::result::Result<VectorIndex, String> {
662    let one = |p: &str| -> std::result::Result<Option<&Term>, String> {
663        let mut it = props.iter().filter(|(k, _)| k == p).map(|(_, v)| v);
664        let first = it.next();
665        if it.next().is_some() {
666            return Err(format!("more than one {}", crate::functions::local_name(p)));
667        }
668        Ok(first)
669    };
670    let from_iri = s
671        .strip_prefix(INDEX_PREFIX)
672        .ok_or_else(|| format!("an index must be named <{INDEX_PREFIX}NAME>"))?;
673    let name = match one(vocab::INDEX_NAME)? {
674        Some(Term::Literal(l)) => l.value().to_owned(),
675        Some(_) => return Err("indexName must be a string".into()),
676        None => from_iri.to_owned(),
677    };
678    if name != from_iri {
679        return Err(format!(
680            "indexName {name:?} differs from the name in the IRI ({from_iri:?})"
681        ));
682    }
683    let property = match one(vocab::PROPERTY)? {
684        Some(Term::NamedNode(n)) => n.clone(),
685        Some(_) => return Err("property must be an IRI".into()),
686        None => return Err("property is missing".into()),
687    };
688    let dimensions = match one(vocab::DIMENSIONS)? {
689        Some(Term::Literal(l))
690            if crate::encoding::numeric_rank(l.datatype().as_str()) == Some(1)
691                || l.datatype() == xsd::INTEGER =>
692        {
693            l.value()
694                .parse::<u32>()
695                .map_err(|_| format!("dimensions {} is not a positive integer", l.value()))?
696        }
697        Some(_) => return Err("dimensions must be an integer".into()),
698        None => return Err("dimensions is missing".into()),
699    };
700    let metric = match one(vocab::METRIC)? {
701        Some(Term::NamedNode(n)) => {
702            Metric::from_iri(n.as_str()).ok_or_else(|| format!("unknown metric <{n}>"))?
703        }
704        Some(_) => return Err("metric must be an IRI".into()),
705        None => Metric::Cosine,
706    };
707    let element_type = match one(vocab::ELEMENT_TYPE)? {
708        Some(Term::NamedNode(n)) => ElementType::from_iri(n.as_str())
709            .ok_or_else(|| format!("unknown element type <{n}>"))?,
710        Some(_) => return Err("elementType must be an IRI".into()),
711        None if metric == Metric::Jaccard => ElementType::SparseFloat32,
712        None => ElementType::Float32,
713    };
714    let class = match one(vocab::CLASS)? {
715        Some(Term::NamedNode(n)) => Some(n.clone()),
716        Some(_) => return Err("class must be an IRI".into()),
717        None => None,
718    };
719    let d = VectorIndex {
720        name,
721        property,
722        dimensions,
723        metric,
724        element_type,
725        class,
726    };
727    d.validate().map_err(|e| e.to_string())?;
728    Ok(d)
729}
730
731/// Built fingerprints by lower-case index name, from `oxilite_meta` rows `(key, value)`.
732pub fn built_from_meta<'a>(
733    rows: impl IntoIterator<Item = (&'a str, &'a str)>,
734) -> BTreeMap<String, String> {
735    rows.into_iter()
736        .filter_map(|(k, v)| Some((k.strip_prefix(META_PREFIX)?.to_owned(), v.to_owned())))
737        .collect()
738}
739
740/// Is `name`'s table built from this exact definition?
741pub fn is_built(index: &VectorIndex, built: &BTreeMap<String, String>) -> bool {
742    built.get(&index.name.to_lowercase()) == Some(&index.fingerprint())
743}
744
745/// Statements making the built tables match the definitions: orphans and stale tables are
746/// dropped, missing and stale ones created. Empty when everything is current.
747pub fn sync_statements(
748    defs: &[VectorIndex],
749    built: &BTreeMap<String, String>,
750    caps: &Capabilities,
751) -> Result<Vec<Statement>> {
752    let mut s = Vec::new();
753    for name in built.keys() {
754        if !defs.iter().any(|d| d.name.to_lowercase() == *name) {
755            s.extend(drop_statements(name));
756        }
757    }
758    for d in defs {
759        if is_built(d, built) {
760            continue;
761        }
762        if built.contains_key(&d.name.to_lowercase()) {
763            s.extend(drop_statements(&d.name));
764        }
765        s.extend(d.create_statements(caps)?);
766    }
767    Ok(s)
768}
769
770/// Can this update change a vector index definition? Any triple written to `<oxilite:vectors>`
771/// counts; with a variable graph, a triple counts when it could be a definition triple (a
772/// variable predicate, an `oxl:` predicate, or `rdf:type` with a variable or `oxl:` class).
773/// `LOAD`, `CLEAR` and `DROP` always count: re-checking is one read when nothing changed.
774pub fn update_touches_vectors(update: &spargebra::Update) -> bool {
775    use spargebra::term::{GraphName as G, GraphNamePattern, NamedNodePattern, TermPattern};
776    use spargebra::GraphUpdateOperation as Op;
777    let definition_triple = |p: &NamedNodePattern, o: &TermPattern| match p {
778        NamedNodePattern::Variable(_) => true,
779        NamedNodePattern::NamedNode(n) if n.as_ref() == rdf::TYPE => match o {
780            TermPattern::NamedNode(c) => c.as_str().starts_with(NS),
781            TermPattern::Variable(_) => true,
782            _ => false,
783        },
784        NamedNodePattern::NamedNode(n) => n.as_str().starts_with(NS),
785    };
786    let counts = |g: &GraphNamePattern, p: &NamedNodePattern, o: &TermPattern| match g {
787        GraphNamePattern::NamedNode(n) => n.as_str() == VECTORS_GRAPH,
788        GraphNamePattern::DefaultGraph => false,
789        GraphNamePattern::Variable(_) => definition_triple(p, o),
790    };
791    let data = |g: &G| matches!(g, G::NamedNode(n) if n.as_str() == VECTORS_GRAPH);
792    update.operations.iter().any(|op| match op {
793        Op::InsertData { data: d } => d.iter().any(|q| data(&q.graph_name)),
794        Op::DeleteData { data: d } => d.iter().any(|q| {
795            matches!(&q.graph_name, spargebra::term::GraphName::NamedNode(n) if n.as_str() == VECTORS_GRAPH)
796        }),
797        Op::DeleteInsert { delete, insert, .. } => {
798            delete.iter().any(|q| {
799                let o: TermPattern = q.object.clone().into();
800                counts(&q.graph_name, &q.predicate, &o)
801            }) || insert
802                .iter()
803                .any(|q| counts(&q.graph_name, &q.predicate, &q.object))
804        }
805        Op::Create { .. } => false,
806        Op::Load { .. } | Op::Clear { .. } | Op::Drop { .. } => true,
807    })
808}
809
810/// The index a `SERVICE` IRI names.
811pub fn service_index(iri: &str) -> Option<&str> {
812    iri.strip_prefix(INDEX_PREFIX)
813}
814
815/// Finds a definition by exact name, else ignoring case.
816pub fn find<'a>(defs: &'a [VectorIndex], name: &str) -> Option<&'a VectorIndex> {
817    defs.iter()
818        .find(|d| d.name == name)
819        .or_else(|| defs.iter().find(|d| d.name.eq_ignore_ascii_case(name)))
820}
821
822/// The id of the default graph, for callers building their own statements.
823pub const DEFAULT_GRAPH: i64 = DEFAULT_GRAPH_ID;
824
825#[cfg(test)]
826mod tests {
827    use super::*;
828
829    fn ex(l: &str) -> NamedNode {
830        NamedNode::new_unchecked(format!("http://example.com/{l}"))
831    }
832
833    #[test]
834    fn definitions_round_trip_through_rdf() {
835        let d = VectorIndex::new("Docs", ex("embedding"), 3)
836            .metric(Metric::Euclidean)
837            .element_type(ElementType::Float64)
838            .class(ex("Doc"));
839        let (defs, problems) = definitions_from_quads(&d.to_quads());
840        assert!(problems.is_empty(), "{problems:?}");
841        assert_eq!(defs, vec![d]);
842    }
843
844    #[test]
845    fn invalid_definitions() {
846        assert!(VectorIndex::new("1x", ex("e"), 3).validate().is_err());
847        assert!(VectorIndex::new("x", ex("e"), 0).validate().is_err());
848        assert!(VectorIndex::new("x", ex("e"), 3)
849            .metric(Metric::Jaccard)
850            .validate()
851            .is_err());
852        let mut quads = VectorIndex::new("x", ex("e"), 3).to_quads();
853        quads.retain(|q| q.predicate.as_str() != vocab::DIMENSIONS);
854        let (defs, problems) = definitions_from_quads(&quads);
855        assert!(defs.is_empty());
856        assert!(
857            problems[0].contains("dimensions is missing"),
858            "{problems:?}"
859        );
860    }
861
862    #[test]
863    fn knn_checks_the_query() {
864        let d = VectorIndex::new("x", ex("e"), 3);
865        assert!(knn_sql(&d, &QueryVector::vector(&[1.0, 2.0]), 5)
866            .unwrap_err()
867            .to_string()
868            .contains("2 dimensions"));
869        assert!(knn_sql(&d, &QueryVector::Vector("nope".into()), 5).is_err());
870        assert!(knn_sql(&d, &QueryVector::vector(&[1.0, 2.0, 3.0]), 0).is_err());
871        let sql = knn_sql(&d, &QueryVector::vector(&[1.0, 2.0, 3.0]), 5).unwrap();
872        assert!(
873            sql.contains("vector_distance_cos(v.e, vector32('[1,2,3]'))"),
874            "{sql}"
875        );
876    }
877
878    #[test]
879    fn sync_plans() {
880        let caps = Capabilities {
881            vectors: true,
882            ..Capabilities::native()
883        };
884        let d = VectorIndex::new("x", ex("e"), 3);
885        let mut built = BTreeMap::new();
886        assert!(!sync_statements(std::slice::from_ref(&d), &built, &caps)
887            .unwrap()
888            .is_empty());
889        built.insert("x".to_owned(), d.fingerprint());
890        assert!(sync_statements(std::slice::from_ref(&d), &built, &caps)
891            .unwrap()
892            .is_empty());
893        let drop = sync_statements(&[], &built, &caps).unwrap();
894        assert!(drop.iter().any(|s| s.sql == "DROP TABLE IF EXISTS vec_x"));
895        assert!(d.create_statements(&Capabilities::native()).is_err());
896    }
897}