Skip to content

Latest commit

 

History

History
253 lines (198 loc) · 12.8 KB

embeddings.md

File metadata and controls

253 lines (198 loc) · 12.8 KB

Semantic Embeddings (Poincare Vector Store)

Open Ontologies has a dual-space vector store for semantic search and alignment, available in builds compiled with the embeddings feature:

  • Text embeddings via a local ONNX model (paraphrase-multilingual-MiniLM-L12-v2, 384 dimensions) that captures label and definition similarity across languages
  • Structural embeddings via Poincare ball — captures hierarchy position (root classes near center, leaves near boundary)
  • Product search — combines both spaces for best results
onto_load → onto_embed → onto_search "domestic animal"

This subsystem is a compile-time feature. Build with cargo build --release --features embeddings. The pre-built binaries and the GHCR image do not have it, and on those onto_embed, onto_search, onto_similarity and onto_hnsw_build return Compiled without embeddings feature. Rebuild with --features embeddings. On a build that has the feature, open-ontologies init downloads a 448 MB ONNX model and a 16 MB tokenizer into ~/.open-ontologies/models. All inference then runs locally via tract, a pure Rust ONNX runtime, with no API keys and no external services.

Tools

Tool Purpose
onto_embed Generate embeddings for all classes in the loaded ontology
onto_search Semantic search by natural language query
onto_similarity Compare two IRIs by embedding similarity

Search Modes

Mode What it uses
text Cosine similarity on text embeddings only
structure Poincare distance on structural embeddings only
product Weighted combination of both (default, alpha=0.5)

Cosine index backends

The text (cosine) half of the store has two interchangeable index backends. The structural (Poincare) half has one, and will keep having one: TurboQuant scores inner products, and hyperbolic distance is not an inner product on the ambient coordinates.

embeddings (the default index backend) turbovec
Algorithm HNSW graph (instant-distance) TurboQuant quantiser (turbovec, arXiv:2504.19874)
Entry point VecStore::search_cosine_hnsw VecStore::search_cosine_turbo
Cost of one added embedding Full graph rebuild, the graph is immutable One append
Cost of one removal Full graph rebuild O(1)
Storage float32 in the graph 4 bit codes plus a per-vector scale
Scores returned Exact for whichever entries the graph walk reaches Exact, the shortlist is re-scored against float32
Approximation lives in Which entries the walk reaches Which entries the quantised shortlist contains

Build it with cargo build --features turbovec. The feature implies embeddings; it replaces one backend rather than the whole subsystem, and the exact brute-force search_cosine stays available under both.

search_cosine_turbo treats the quantised index as a candidate generator only. It pulls a shortlist four times wider than the request (floor of top_k + 32), re-scores every candidate against the float32 vector the store already holds, and returns that. So a quantised similarity number never reaches a caller, and the result is identical to the exact scan whenever the shortlist covers the true top-k.

Both backends persist into hnsw_index_cache under their own kind (cosine, poincare, turbo_cosine), and both are gated by the same two checks on load: model_fp rejects an index built under a different embedding configuration, and entries_hash rejects one whose entry set has moved on.

Measured

10,000 vectors x 768 dims of isotropic random data, Apple M3 Max, release build, measure_turbo_against_hnsw in tests/turbovec_index_test.rs:

HNSW TurboQuant
Build 116 s 178 ms
One added embedding 137 s (full rebuild) 52 us
Query, top-10 4.7 ms 0.25 ms (pulling 40 candidates)
recall@10 as wired 91.6% 100%
recall@10, 40 candidates re-scored to 10 91.6% 100%
Serialised index 34.1 MB 5.2 MB

Raw float32 for that corpus is 30.7 MB, so the HNSW blob is larger than the vectors it indexes and the TurboQuant one is a sixth of them. Build and rebuild times move by a factor of several between runs depending on machine load; the query, recall and size figures are stable.

Do not read the recall rows as a reason to switch. They come from isotropic random vectors, which is close to the worst case for a graph index: every pairwise cosine sits near zero, so ranks 10 and 11 are separated by noise and there is no cluster structure for the walk to exploit. On a real corpus the gap all but disappears. measure_recall_on_a_real_corpus embeds 10,000 real ontology labels with the shipped local MiniLM model over two contrasting real corpora, one a topical taxonomy and one a set of real-world entity names:

Corpus (10,000 labels, 384 dims) mean pairwise cosine HNSW as wired HNSW, 40 re-scored to 10 TurboQuant
Learning-standards labels 0.25 99.9% 99.9% 100%
Securities-register entity names 0.34 99.8% 99.8% 100%
Isotropic random (for contrast) ~0 91.6% 91.6% 100%

So HNSW's recall is data-dependent and TurboQuant's is not, but on data that looks like anything you would actually embed, HNSW is already effectively exact. The honest reasons to prefer the TurboQuant backend are mutation cost and index size, not accuracy.

The one structural point the synthetic run does establish: over-fetching does not help a graph index. Giving HNSW 40 candidates instead of 10 leaves its recall unchanged to the digit in both corpora, because the entries it misses are ones the walk never reaches and ef_search is the only lever. A flat quantised scan cannot have that failure mode, because it scores every vector.

When to pick which. HNSW if the ontology is embedded once and then only queried. TurboQuant if embeddings arrive incrementally (the rebuild is what you are paying for, not the query), or if the corpus is large enough that float32 storage is the constraint: at 768 dims a 4 bit code is 388 bytes against 3,072.

Evicting float32 text vectors

VecStore normally keeps every float32 text vector in memory for the lifetime of the process. That makes TurboQuant's compression a smaller SQLite blob rather than less RAM, because the codes and the vectors they were made from are both resident.

let store = VecStore::new(db).with_text_vectors_evicted();

In eviction mode the float32 lives only in the embeddings table. Upserts write through to the row before touching memory, removals delete it, and reads load on demand: fetch_text_vecs pulls just the shortlist for the exact re-score, all_text_vecs streams the whole set for the paths that genuinely need every vector (the exact scan, the product search, index builds, the fingerprint). resident_text_vector_bytes() reports what is still held, and it is zero under eviction.

Two things to know before turning it on:

  • It is only coherent with the TurboQuant backend. An instant-distance graph holds its own float32 copy of every point, so evicting the store's copy while querying through HNSW moves the memory rather than saving it.
  • get_text_vec returns None under eviction, because there is no in-memory vector to borrow. load_text_vec returns an owned vector and works in both modes; every internal caller uses it.

An evicted store and a resident one over the same database produce the same entry-set fingerprint, so they can share persisted index caches.

Measured at 20,000 vectors x 384 dims on an M3 Max (measure_eviction_cost in tests/vecstore_eviction_test.rs):

Resident Evicted
float32 held in memory 30.7 MB 0 MB
search_cosine_turbo, top-10 312 us 387 us (1.2x)
search_cosine, exact scan 16.4 ms 39.4 ms (2.4x)

The turbo path pays about 75 us per query for the shortlist fetch, which is the trade the mode exists to make. The exact scan pays much more, because it reads every row: if a workload leans on search_cosine rather than search_cosine_turbo, do not evict.

Providers

The text-embedding side is pluggable via [embeddings] provider:

Provider When to use
local (default) Offline / air-gapped; no API keys; bge-small-en-v1.5 ONNX runs in-process via tract
openai Any OpenAI-compatible HTTP gateway: official OpenAI, Azure OpenAI, Ollama, vLLM, LocalAI, LM Studio, Together, Mistral, etc.

Configuring the OpenAI-compatible provider

[embeddings]
provider = "openai"
api_base = "https://api.openai.com/v1"   # alias: base_url
api_key  = "sk-..."                       # optional — env vars take precedence
model    = "text-embedding-3-small"       # any model your gateway serves
dimensions = 1536                         # optional — only sent when set
request_timeout_secs = 30

Trailing slashes on api_base are stripped automatically. The gateway must accept POST {api_base}/embeddings with the OpenAI request shape.

Environment variables (override config)

Variable Purpose Precedence
OPEN_ONTOLOGIES_EMBEDDINGS_PROVIDER Force local or openai Highest
OPEN_ONTOLOGIES_EMBEDDINGS_API_BASE Override gateway URL Highest
OPEN_ONTOLOGIES_EMBEDDINGS_API_KEY Override bearer token Highest
OPENAI_API_KEY Bearer token fallback when the dedicated var is unset Higher than config
OPEN_ONTOLOGIES_EMBEDDINGS_MODEL Override model name Highest

Auth is optional — many local gateways (Ollama, LocalAI) accept unauthenticated requests, so the resolver returns None rather than failing when no key is configured.

Example: Ollama (local OpenAI-compatible gateway)

[embeddings]
provider = "openai"
api_base = "http://localhost:11434/v1"
model    = "nomic-embed-text"
# api_key is unnecessary for local Ollama

Changing the model or provider

Stored vectors are only comparable against queries from the same configuration. Two models of the same dimension produce numbers of the same shape and no shared meaning, so mixing them does not fail — retrieval quality just quietly drops.

Every stored vector and every cached HNSW index therefore carries model_fp, a composite hash of (provider, model, revision):

provider model revision
local model file name sha256 of the whole file
openai-compatible resolved model name, plus api_base and dimensions none — the API exposes no revision

The fingerprint describes the effective configuration, so an environment override (OPEN_ONTOLOGIES_EMBEDDINGS_MODEL, …) counts as a change. For the local provider it hashes the file contents: replacing the .onnx in place leaves the path and filename identical, and that is the most likely way a local model gets swapped.

The whole file, not a head prefix — two fine-tunes of one architecture have identical sizes and identical ONNX graph protos, and cp -p preserves mtime, so (size, mtime, head) cannot tell them apart. Cost, measured on the 470 MB default model: 1.4 s, once per process, and only when the local provider is in use — against a model load that already reads and optimises the same file.

On a mismatch, the affected vectors and index caches are discarded and rebuilt, with a warn naming what happened. What to expect:

  • After changing [embeddings] model, provider, api_base or dimensions — everything must be re-embedded. Discarding is not optional: the old vectors cannot be compared against new queries.
  • The first start after upgrading to a build that has this column — vectors written earlier carry no fingerprint. Nothing recorded which model produced them, and assuming it was the current one is the failure this prevents, so they are discarded once. Re-embed and it does not recur.
  • No change — nothing is discarded and cached indices are reused, exactly as before.

entries_hash is unchanged and still checked. It catches a changed entry set; model_fp catches an unchanged entry set queried by a different model — which entries_hash structurally cannot see, since the stored bytes are identical.

Notes

  • API responses are L2-normalized in-process so cosine scores remain comparable with the local ONNX path.
  • The local and remote paths share the same downstream onto_embed / onto_search / onto_similarity tools — switching providers requires no code changes.
  • See [embeddings] block in the default config emitted by open-ontologies init (src/main.rs::DEFAULT_CONFIG) for the full set of fields with comments.
  • Changing the tokenizer alone (same .onnx, different tokenizer.json) is covered: its sha256 is folded into the local arm's revision alongside the model's, because a different tokenizer produces different vectors from the same model.