Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Vector search

The library supports K-nearest neighbour (KNN) vector search in Core queries: find_nearest keeps the documents whose vector field is nearest to a query vector.

A vector is stored in a field of type FirestoreVector, which serialises to a Firestore vector value:

use firestore::*;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Deserialize, Serialize)]
struct Article {
    title: String,
    embedding: FirestoreVector,
}

let article = Article {
    title: "Firestore editions".to_string(),
    embedding: FirestoreVector::new(vec![0.1, 0.9, 0.3]),
};
let _ = article;

vec![..].into() builds a FirestoreVector too, from any iterator of f64.

Reading a vector from a document

A document read without a struct, as in Working on dynamic/document level, holds each vector as a map of __type__ and value. FirestoreVector::from_value reads a vector from such a field:

use firestore::*;
fn example(document: &FirestoreDocument) -> FirestoreResult<()> {
if let Some(value) = document.fields.get("embedding") {
    let embedding = FirestoreVector::from_value(value)?;
    println!("{} components", embedding.as_vec().len());
}
Ok(())
}

Any other value, such as a plain map, fails with a DeserializeError of code NOT_A_VECTOR.

The vector index

On a Standard edition database a vector query needs a vector index on the field, and uses it. The index declares the dimension every indexed vector in the field must have. Without one the query fails with FAILED_PRECONDITION, “Missing vector index configuration”, and the message carries the gcloud command that creates the index. Index management declares one with .vector(dimension), and the console or gcloud create one too.

Finding the nearest documents

find_nearest(field, vector, measure, limit) after the source and filters keeps the limit documents nearest to vector, nearest first:

use firestore::*;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Deserialize, Serialize)]
struct Article { title: String, embedding: FirestoreVector }
async fn example(db: FirestoreDb) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let nearest: Vec<Article> = db
    .fluent()
    .select()
    .from("articles")
    .find_nearest(
        path!(Article::embedding),
        FirestoreVector::new(vec![0.1, 0.8, 0.2]),
        FirestoreFindNearestDistanceMeasure::Cosine,
        5,
    )
    .obj()
    .query()
    .await?;
let _ = nearest;
Ok(())
}

FirestoreFindNearestDistanceMeasure has three measures:

  • Euclidean, the straight-line distance between the two vectors;
  • Cosine, which compares the angle between the vectors and ignores their length;
  • DotProduct, which is like cosine, and takes the length into account too.

Documents at the same distance come back in no fixed order. A Cosine query fails with FAILED_PRECONDITION, “Cannot compute cosine distance against a vector with a magnitude of zero”, when the collection holds a vector of zeros.

Distance and threshold

find_nearest_with_options(..) takes a FirestoreFindNearestOptions, with two more settings:

  • .with_distance_result_field(name) outputs the distance of each document under that field, so the result type can read it. It holds the straight-line distance for Euclidean, the cosine distance for Cosine (0 for the same direction), and the dot product itself for DotProduct;
  • .with_distance_threshold(value) returns only the documents within that distance. For Euclidean and Cosine it is the largest distance, and a document at exactly that distance is kept. For DotProduct, where a larger value means a nearer vector, it is the smallest one.
use firestore::*;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Deserialize, Serialize)]
struct Article {
    title: String,
    embedding: FirestoreVector,
    distance: Option<f64>,
}
async fn example(db: FirestoreDb) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let nearest: Vec<Article> = db
    .fluent()
    .select()
    .from("articles")
    .find_nearest_with_options(
        FirestoreFindNearestOptions::new(
            path!(Article::embedding),
            FirestoreVector::new(vec![0.1, 0.8, 0.2]),
            FirestoreFindNearestDistanceMeasure::Euclidean,
            10,
        )
        .with_distance_result_field(path!(Article::distance))
        .with_distance_threshold(0.5),
    )
    .obj()
    .query()
    .await?;
let _ = nearest;
Ok(())
}

Full example available here.

A vector query is never answered from a cache: under read_through_cache it goes to Firestore, and under read_cached_only it fails with CacheIncompleteCollection.

On the Enterprise edition

A Core vector query runs on an Enterprise edition database without a vector index. Query Explain is refused there, so its plan cannot show what the query reads.

The pipeline find_nearest stage takes the same vector and measures, and has no distance threshold. It does not use a vector index: its plan is a table scan, so its cost follows the size of the collection, see Query plans and cost.

Google lists this as a known issue of pipelines: they do not yet support the vector index type, and find_nearest is expected to be slower than its Core equivalent, see Known issues in the pipelines overview. Until pipelines use vector indexes:

  • a filter before the find_nearest stage does not narrow what the stage reads. The documents are read first and the filter is applied to them afterwards, even when a composite index of the filter field and the vector field exists;
  • a Core find_nearest is the query that can use a vector index, so use it where the index should be used. Enterprise rejects a Core .explain(), so whether it does there cannot be seen.