Skip to content

Embeddings

Two ways to keep vectors: as a field of an entity, versioned with it, or beside the text of graph nodes. This page starts with graph nodes.

Vectors for the text of graph nodes, kept beside them, and the nodes nearest to a text or a vector. The store needs the Vectors capability: drevo has it, and searches by cosine without a separate index.

from rhizodat.embed import embed_nodes, load_embedder, similar

embedder = load_embedder("mypkg.models:SentenceEmbedder")   # yours, see below
report = embed_nodes(dat, store="graph", label="Chapter", text="summary", embedder=embedder)
report.embedded, report.unchanged

for n in similar(dat, store="graph", label="Chapter", query="a stormy night", embedder=embedder, k=5):
    print(n.score, n.key, n.properties["summary"])
rhizodat embed --store graph --label Chapter --text summary --model mypkg.models:SentenceEmbedder
rhizodat similar --store graph --label Chapter --model mypkg.models:SentenceEmbedder "a stormy night"

Embedders

An embedder has a model (its name and version), dimensions and embed(texts) -> list of vectors. rhizodat ships no model that understands meaning. Wrap the one you use (an API client, a local model) in a small class and name it as module:Class; it is constructed with no arguments:

class SentenceEmbedder:
    model = "all-MiniLM-L6-v2@2"
    dimensions = 384

    def __init__(self) -> None:
        from sentence_transformers import SentenceTransformer
        self._m = SentenceTransformer("all-MiniLM-L6-v2")

    def embed(self, texts: list[str]) -> list[list[float]]:
        return self._m.encode(texts).tolist()

hash / hash:<dimensions> is the built-in HashEmbedder: words hashed into buckets, no model and no network. Equal texts give equal vectors and shared words bring texts closer, but it knows no meaning. It is for tests and offline demos.

What is stored, and when it is made again

The vector goes into a node property (embedding unless property= says otherwise), with the model that made it (embedding_model) and a hash of the text (embedding_hash). A second embed_nodes embeds only nodes whose text changed, or every node after a model change, and writes all new vectors in one transaction: a failure (the model's or the store's) writes none. similar returns the nodes' properties without the vector and these two.

Search with the model that made the stored vectors: vectors of different models are not comparable.

Vector fields on entities

A field marked Vector(n) holds an embedding of n floats. It is an ordinary field of the entity: written in the same Unit of Work as the rest, in any store, so re-embedding is a new version. Keep the model next to it.

from typing import Annotated
from rhizodat import Vector, entity

@entity(stores={"pg": ["title", "embedding", "model"]})
class Issue:
    title: str
    embedding: Annotated[list[float], Vector(384)] | None
    model: str | None

issues = dat.repository(Issue)
issues.add(Issue(title=title, embedding=embedder.embed([title])[0], model=embedder.model))

for m in issues.similar("embedding", "login crashes", embedder=embedder, k=5):
    print(m.score, m.entity.title)

issues.similar("embedding", vector, as_of=last_week)   # the vectors valid then
issues.get(issue_id, as_of=last_week).model            # which model made them

A write checks the vector: n finite numbers, stored as floats; None is allowed when the field is optional. similar takes a vector or a text (with the embedder that made the stored vectors) and returns a Match per entity, closest first: the whole entity as get would return it at the same as_of / known_at, and its cosine score. Soft-deleted entities and entities without a vector then are left out.

The search is exact: it scans the versions the store knew at that moment (ScansParts) and compares every vector, with no index. That keeps the past searchable and is fine for thousands of entities. Stores keep the vector the way they keep any list of floats, so it reads back exactly; pgvector is not needed.