Semantic Search¶
Store embedding vectors alongside nodes and query them with fast similarity search. Embeddings are stored separately from node properties — they don’t appear in collect(), to_df(), or regular Cypher property access.
Text-Level API (Recommended)¶
Register an embedding model once, then embed and search using text column names. The model runs on the Python side — KGLite only stores the resulting vectors.
from sentence_transformers import SentenceTransformer
class Embedder:
def __init__(self, model_name="all-MiniLM-L6-v2"):
self._model_name = model_name
self._model = None
self._timer = None
self.dimension = 384 # set in load() if unknown
def load(self):
"""Called automatically before embedding. Loads model on demand."""
import threading
if self._timer:
self._timer.cancel()
self._timer = None
if self._model is None:
self._model = SentenceTransformer(self._model_name)
self.dimension = self._model.get_sentence_embedding_dimension()
def unload(self, cooldown=60):
"""Called automatically after embedding. Releases after cooldown."""
import threading
def _release():
self._model = None
self._timer = None
self._timer = threading.Timer(cooldown, _release)
self._timer.start()
def embed(self, texts: list[str]) -> list[list[float]]:
return self._model.encode(texts).tolist()
# Register once on the graph
graph.set_embedder(Embedder())
# Embed a text column — stores vectors as "summary_emb" automatically
graph.embed_texts("Article", "summary")
# Embedding Article.summary: 100%|████████| 1000/1000 [00:05<00:00]
# → {'embedded': 1000, 'skipped': 3, 'skipped_existing': 0, 'dimension': 384}
# Search with text — resolves "summary" → "summary_emb" internally
results = graph.select("Article").search_text("summary", "machine learning", top_k=10)
# [{'id': 42, 'title': '...', 'type': 'Article', 'score': 0.95, ...}, ...]
Key details:
Auto-naming: text column
"summary"→ embedding store key"summary_emb"(auto-derived)Incremental, three modes:
embed_texts(mode=…)—'missing'(default) embeds only nodes without a vector;'changed'also re-embeds nodes whose text changed since the last pass (a per-node content hash is stored to detect this);'all'rebuilds the whole store.Progress bar: shows a tqdm progress bar by default. Disable with
show_progress=False.Load/unload lifecycle: if the model has optional
load()/unload()methods, they are called automatically before and after each embedding operation.Provenance: if the embedder exposes a
model_id/model_nameattribute, it’s stamped onto the store;embedding_info()surfaces it so a model swap is detectable. The model object itself is not saved withsave()— callset_embedder()again after deserializing.
# Add new articles, then re-embed — only new ones are processed
graph.embed_texts("Article", "summary")
# → {'embedded': 50, 'skipped': 0, 'skipped_existing': 1000, 'reembedded_changed': 0, ...}
# Edit some article summaries, then re-embed ONLY what changed:
graph.embed_texts("Article", "summary", mode="changed")
# → {'embedded': 12, 'reembedded_changed': 12, 'skipped_existing': 1038, ...}
# Inspect provenance (dimension, count, model id, metric, #hashed):
graph.embedding_info("Article", "summary")
# → {'dimension': 384, 'count': 1050, 'model': 'all-MiniLM-L6-v2', 'metric': 'cosine', 'hashed': 1050}
# Or just the dimension (cheap; None if no store) — handy to detect a model swap:
graph.embedding_dim("Article", "summary") # → 384
# Combine with filters
results = (graph
.select("Article")
.where({"category": "politics"})
.search_text("summary", "foreign policy", top_k=10))
Carrying vectors across a rebuild¶
The common “rebuild a fresh graph from a source of truth on each load” workflow
needs the vectors carried forward. copy_embeddings_from does it in one call,
matched by node id (carrying dimension, metric, model id, and the per-node text
hashes — so a following mode='changed' only re-embeds genuinely new text):
new_graph = build_from_source() # fresh, no vectors yet
new_graph.copy_embeddings_from(old_graph) # carry every store by node id
new_graph.embed_texts("Article", "summary", mode="changed") # fill only the new/changed
# → {'stores_copied': 1, 'vectors_copied': 1050, 'vectors_skipped': 0} (from copy_embeddings_from)
Low-Level Vector API¶
If you manage vectors yourself, use the low-level API:
Storing Embeddings¶
# Explicit: pass a dict of {node_id: vector}.
# set_embeddings REPLACES the whole store for ('Article', 'summary_emb').
graph.set_embeddings('Article', 'summary', {
1: [0.1, 0.2, 0.3, ...],
2: [0.4, 0.5, 0.6, ...],
})
# Or auto-detect during add_nodes with column_types
df = pd.DataFrame({
'id': [1, 2, 3],
'title': ['A', 'B', 'C'],
'text_emb': [[0.1, 0.2], [0.3, 0.4], [0.5, 0.6]],
})
graph.add_nodes(df, 'Doc', 'id', 'title', column_types={'text_emb': 'embedding'})
Incremental ingest — add_embeddings¶
set_embeddings is a full replace: each call discards the existing
store for (node_type, '{text_column}_emb'). When you ingest documents in
batches — embed chunks for doc A, then doc B, then doc C — a second
set_embeddings call would wipe doc A’s vectors.
Use add_embeddings for that. It upserts into the existing store
(creating it on the first call), so batches coexist without a
read-merge-write cycle in your own code:
graph.add_embeddings('Chunk', 'text', { # doc A's chunks
'a:1': [0.1, 0.2, ...],
'a:2': [0.3, 0.4, ...],
})
graph.add_embeddings('Chunk', 'text', { # doc B's chunks — A's survive
'b:1': [0.5, 0.6, ...],
})
# -> {'embeddings_stored': int, 'dimension': int, 'skipped': int, 'store_created': bool}
Reach for set_embeddings only when you genuinely want to replace the
entire store (e.g. re-embedding everything with a new model).
Both calls require text_column to name a property that exists on the node
type (id, title and type are always accepted) — the guard that catches
passing the store name 'summary_emb' where the column name 'summary'
belongs. Both resolve every id and check every dimension before writing, so a
rejected batch leaves the store as it was, and both count ids that match no
node in skipped.
A store built this way records exactly what you supply: the vectors, their
dimension, and the metric. embed_texts() additionally records the embedder’s
model_id and per-node text hashes, which is what lets
embed_texts(mode='changed') re-embed only the rows whose text moved — over a
raw-vector store it re-embeds every row.
Call save() to persist a store: embeddings ride the checkpoint, so a durable
graph writes them at save() rather than per-call.
Vector Search¶
Each hit is a dict with id, title, type, score, and all node
properties. score is always present (every metric), and properties are
read live from the node — so a hit carries the same fields before and after
save() + reload. You don’t need a follow-up MATCH ... WHERE id IN [...]
to recover properties.
# Basic search — each hit carries id, title, type, score AND every node
# property (read live, so no follow-up MATCH...WHERE id IN [...] hydrate needed).
results = graph.select('Article').vector_search('summary', query_vec, top_k=10)
# [{'id': 5, 'title': '...', 'type': 'Article', 'score': 0.95, ...all props...}, ...]
# Trim the payload with returning= → id + score + only the named fields
# (ranking-heavy or wide-node workloads):
ranked = graph.select('Article').vector_search(
'summary', query_vec, top_k=50, returning=['title']) # → {'id', 'score', 'title'}
# Filtered search — only search within a subset
results = (graph
.select('Article')
.where({'category': 'politics'})
.vector_search('summary', query_vec, top_k=10))
# DataFrame output
df = graph.select('Article').vector_search('summary', query_vec, top_k=10, to_df=True)
# Distance metrics: 'cosine' (default), 'dot_product', 'euclidean', 'poincare'
results = graph.select('Article').vector_search(
'summary', query_vec, top_k=10, metric='dot_product')
Scaling search with an index (HNSW)¶
By default vector search is an exact brute-force scan: every candidate is scored. That’s the right thing for small/medium stores and for filtered searches — but on a large corpus, scoring every vector on every query doesn’t scale. Build an HNSW approximate-nearest-neighbour index once, and whole-corpus queries become sub-linear:
graph.embed_texts('Article', 'summary') # produce vectors
graph.build_vector_index('Article', 'summary') # opt in (like create_index)
# vector_search / search_text now auto-use the index for whole-corpus queries:
hits = graph.select('Article').search_text('summary', 'machine learning', top_k=10)
# ...and for a whole-graph search, even when the graph holds other node types:
hits = graph.search_text('summary', 'machine learning', top_k=10)
# Force an exact scan when you need guaranteed-exact results:
hits = graph.select('Article').vector_search('summary', query_vec, top_k=10, exact=True)
It behaves like create_index: opt-in, and once built it’s used
automatically. Key points:
Auto-use, with an escape hatch. A query that covers most of a large indexed store (≥400 covered vectors) uses the index;
exact=Truealways forces the exact scan. The scores returned are on the exact same scale as the brute-force path (the index only narrows which nodes get scored).The selection doesn’t have to be that one type. While only one node type carries the column, the index is used whatever else the selection spans — including a whole-graph
graph.vector_search('summary', q)on a graph full of other node types. Nodes of other types simply have no vector in the store and are skipped, exactly as the exact scan skips them.Two embedded types are ranked exactly. If both
Article.summaryandNote.summaryare embedded, a selection spanning both falls back to an exact global ranking: using one type’s index would silently drop the other type’s rows. Select a single type to get the index back.Filtered queries stay exact. A selective
.where(...)before the search falls back to an exact scan automatically — correctness over speed when a filter is tight. (So an index helps “search the whole corpus”, not “search a small filtered slice”.)Approximate. Recall depends on your data and
ef_search: well-structured embeddings (sentence-transformers, bge, OpenAI, etc.) typically get ≥0.99 recall@10 at the defaults; raiseef_searchfor higher recall at some latency cost, or useexact=Truewhen you can’t tolerate any miss.Benchmark HNSW on real embeddings, not random vectors. Random unit vectors in high dimensions have no neighbourhood structure — every pair is nearly orthogonal (all cosine sims ≈ 0) — so any ANN scores terribly on them (recall can look like ~0.2). That’s the curse of dimensionality, not an engine defect: on real embeddings the same index hits ~0.99. If you must sanity-check on synthetic data, query with stored vectors (which have a true nearest neighbour) rather than fresh random ones.
Metrics. cosine / dot_product / euclidean are indexable;
poincarealways uses the exact path.Lifecycle. Writing vectors (
add_embeddings,embed_texts,set_embeddings) does not drop the index: the write is recorded, and the next vector query folds it in while the outstanding delta stays at or underauto_refresh_limit(default 1000). A larger delta is served by the exact scan — correct, and slower — until you rebuild or callrefresh_vector_index(...). What does drop the index is a change to the slot layout it addresses: deleting an embedded node (the delete prunes its vector), rolling that delete back, andvacuum(). Rebuild after those.SHOW INDEXESreportsstale/delta, plusunembedded— the nodes with no vector at all, which catch-up never embeds. Check withhas_vector_index(...), remove withdrop_vector_index(...).Persisted. The index is saved inside the
.kgl(andto_bytes()), so a reloaded graph keeps it — no rebuild on load.
graph.build_vector_index(
'Article', 'summary',
m=16, # neighbours per node (higher → better recall, larger index)
ef_construction=200, # build-time search width
ef_search=64, # default query-time width (higher → better recall, slower)
)
graph.has_vector_index('Article', 'summary') # True
graph.save('articles.kgl') # index travels with the file
Recall on hard corpora¶
Recall is a property of your vectors, not only of the index. HNSW walks a neighbourhood graph, so it needs neighbourhoods: vectors with real cluster or low-rank structure — what a sentence-embedding model produces — are found essentially exactly at the defaults. Uniform or independent high-dimensional random vectors have no such structure (every pair is nearly orthogonal), the walk has nothing to follow, and recall degrades with both corpus size and dimension. That is the corpus, not a defect: the same index on the same engine is at ~1.0 on the structured corpus and at 0.4 on the unstructured one.
Measured with tests/benchmarks/bench_vector_index.py --recall-sweep (release
build, m=16, ef_construction=200, ef_search=64, cosine, top_k=10,
queries are stored vectors, recall@10 against the exact scan; the range spans
two runs):
corpus |
20k×128 |
50k×128 |
100k×128 |
20k×384 |
50k×384 |
100k×384 |
|---|---|---|---|---|---|---|
clustered / low-rank (realistic) |
≥0.99 |
≥0.99 |
≥0.99 |
≥0.99 |
≥0.99 |
≥0.99 |
independent Gaussian (adversarial) |
0.94 |
0.80 |
0.62–0.71 |
0.77–0.80 |
0.52–0.57 |
0.42 |
The knobs are ef_search, ef_construction and m — all arguments of
build_vector_index. ef_search (default 64) is the query-time one, and its
effect is very different on the two corpora:
On the clustered corpus there is nothing to buy: recall is already ≥0.99 at 64, and raising it only costs latency (100k×384: 0.10 ms at 64, 0.13 ms at 128, 0.19 ms at 256).
On the adversarial corpus it helps, but far less than it costs. At 100k×384: recall 0.42 → 0.43–0.45 → 0.46–0.50 for ef_search 64 → 128 → 256, while latency goes 0.17–0.19 → 0.27–0.33 → 0.49 ms. At 100k×128: 0.62–0.71 → 0.70–0.73 → 0.77–0.80. Index construction inserts concurrently, so two builds of the same corpus differ by ±0.04 recall on this corpus (±0.005 on the clustered one) — an ef_search step of 64 → 128 is inside that noise.
So ef_search=64 stays the default: it is exact-grade on the corpora the
feature exists for, and no reachable setting rescues a corpus with no
neighbourhood structure. If your vectors are in that regime, exact=True is
always available and, at these sizes, cheap: the exact scan is 0.7 ms at
100k×128 and 1.6 ms at 100k×384 (it parallelises above 10k vectors).
The Cypher
text_score()/vector_score()whole-corpus top-k (RETURN vector_score(n, prop, q) AS s ORDER BY s DESC LIMIT k) auto-uses the index too — so agent/MCP semantic search via Cypher benefits as well. The end-to-end win is smaller than the fluent API’s, though: Cypher’s fixed per-query cost (parse + plan + projection) is a bigger share of the total, so the index saving shows through less at small/medium corpus sizes and widens as the corpus grows. A heavily-filtered Cypher query (selectiveWHERE) stays exact.
Choosing a Distance Metric¶
Metric |
Best for |
Why |
|---|---|---|
|
General-purpose text/semantic embeddings (OpenAI, Sentence-Transformers, Cohere) |
Compares direction, ignores magnitude. Works well when embeddings are normalized or you only care about semantic similarity. |
|
Embeddings where magnitude encodes relevance (MIPS) |
Like cosine but magnitude matters — a longer vector scores higher. Useful when the model encodes “importance” in the norm. |
|
Spatial/geometric data, clustering, k-means style lookups |
Raw geometric distance. Best when absolute position in the space matters, not just angle. |
|
Hierarchical/taxonomic data (ontologies, org charts, category trees) |
Hyperbolic geometry naturally encodes tree structure. Nodes near the origin are roots; nodes near the boundary are leaves. 5D Poincaré can outperform 200D Euclidean on hierarchy tasks. |
Rule of thumb: If you’re using off-the-shelf text embeddings, use cosine. If your data has inherent hierarchy and you’ve trained Poincaré embeddings, use poincare.
Stored Metric¶
When embeddings are trained for a specific geometry, store the intended metric alongside them so it becomes the default at query time:
# Store Poincaré embeddings with their intended metric
graph.set_embeddings('Concept', 'title', poincare_vectors, metric='poincare')
# Queries now default to poincaré distance — no need to pass metric= each time
results = graph.select('Concept').vector_search('title', query_vec, top_k=10)
# You can still override explicitly
results = graph.select('Concept').vector_search(
'title', query_vec, top_k=10, metric='cosine')
# list_embeddings() shows the stored metric
graph.list_embeddings()
# [{'node_type': 'Concept', 'text_column': 'title', 'dimension': 5,
# 'count': 500, 'metric': 'poincare'}]
Metric resolution order: explicit metric= argument > stored metric > cosine default.
Semantic Search in Cypher¶
text_score() enables semantic search directly in Cypher queries. Give it a
string query and it embeds that text with the registered model (via
set_embedder()) before scoring; give it a list and it scores your own
query vector directly, needing only the embedding store:
# Requires: set_embedder() + embed_texts()
graph.cypher("""
MATCH (n:Article)
RETURN n.title, text_score(n, 'summary', 'machine learning') AS score
ORDER BY score DESC LIMIT 10
""")
# With parameters
graph.cypher("""
MATCH (n:Article)
WHERE text_score(n, 'summary', $query) > 0.8
RETURN n.title
""", params={'query': 'artificial intelligence'})
# With explicit metric
graph.cypher("""
MATCH (n:Article)
RETURN n.title, text_score(n, 'summary', 'machine learning', 'poincare') AS score
ORDER BY score DESC LIMIT 10
""")
# Combine with graph filters
graph.cypher("""
MATCH (n:Article)-[:CITED_BY]->(m:Article)
WHERE n.category = 'politics'
RETURN m.title, text_score(m, 'summary', 'foreign policy') AS score
ORDER BY score DESC LIMIT 5
""")
Both scoring functions take a pre-computed vector, so scoring works with the embedding store alone:
# Same scores, same ordering — the store is all either one needs.
graph.cypher("MATCH (n:Article) RETURN vector_score(n, 'summary_emb', $q) AS s",
params={'q': query_vec}) # names the store
graph.cypher("MATCH (n:Article) RETURN text_score(n, 'summary', $q) AS s",
params={'q': query_vec}) # names the column
vector_score is the Cypher counterpart of the fluent vector_search()
method. Note the surfaces differ: text_score()/vector_score() are Cypher
functions (used in RETURN/WHERE); search_text()/vector_search() are
fluent methods on a selection.
The query argument’s type decides how text_score reads it — a list is a
vector, a string is text — so a stringified vector like '[1.0, 2.0]' is
embedded as a 10-character query. Pass a list and both spellings agree.
Embedding Norm in Cypher¶
embedding_norm() returns the L2 norm of a node’s embedding vector. In Poincaré space, norm indicates hierarchy depth: values near 0 are roots, values near 1 are leaves.
# Find the most "root-like" concepts (lowest norm = highest in hierarchy)
graph.cypher("""
MATCH (n:Concept)
RETURN n.name, embedding_norm(n, 'title') AS depth
ORDER BY depth ASC LIMIT 10
""")
# Find leaf nodes (high norm = deep in hierarchy)
graph.cypher("""
MATCH (n:Concept)
WHERE embedding_norm(n, 'title') > 0.8
RETURN n.name, embedding_norm(n, 'title') AS depth
""")
Embedding Utilities¶
graph.list_embeddings()
# [{'node_type': 'Article', 'text_column': 'summary', 'dimension': 384, 'count': 1000, 'metric': None}]
graph.remove_embeddings('Article', 'summary')
# Retrieve all embeddings for a type (no selection needed)
embs = graph.embeddings('Article', 'summary')
# {1: [0.1, 0.2, ...], 2: [0.4, 0.5, ...], ...}
# Retrieve embeddings for current selection only
embs = graph.select('Article').where({'category': 'politics'}).embeddings('summary')
# Get a single node's embedding (O(1) lookup, returns None if not found)
vec = graph.embedding('Article', 'summary', node_id)
Embeddings persist across save()/load() cycles automatically.
Embedding Export / Import¶
Export embeddings to a standalone .kgle file so they survive graph rebuilds:
# Export all embeddings
stats = graph.export_embeddings("embeddings.kgle")
# {'stores': 2, 'embeddings': 5000}
# Export only specific node types
graph.export_embeddings("embeddings.kgle", ["Article", "Author"])
# Import into a fresh graph — matches by (node_type, node_id)
result = graph.import_embeddings("embeddings.kgle")
# {'stores': 2, 'imported': 4800, 'skipped': 200}
A .kgle carries each store’s provenance — its metric, the embedder
model_id, and per-node text hashes — so a rebuild-from-.kgle pipeline keeps
it: after import, embedding_info() reports the model/metric, and
embed_texts(mode='changed') re-embeds only genuinely-changed text instead of
everything. Current releases import .kgle v3/Postcard only. Convert v1/v2
files with kglite 0.13.4 by importing them into the matching graph and
re-exporting them before upgrading.