Embedding Search Architecture
Current embedding storage model and search execution paths in NornicDB.
This document is architecture-focused (what exists today in code), and links out to user-facing guides for “how to use it”.
Related docs
- Entry point:
docs/architecture/embedding-search.md - Flow diagrams:
docs/architecture/embedding-search-flow-diagrams.md - Examples:
docs/architecture/embedding-search-examples.md - Vector search usage:
docs/user-guides/vector-search.md - Qdrant gRPC usage:
docs/user-guides/qdrant-grpc.md
Data model: where embeddings live
Embeddings are stored on storage.Node in struct fields (not in node.Properties):
Node.ChunkEmbeddings [][]float32- Used for NornicDB-managed embeddings and chunked document embeddings
- Convention:
ChunkEmbeddings[0]is the “main” node embedding - Additional chunks are stored at
[1..N]for long documents
Node.NamedEmbeddings map[string][]float32- Used for client-managed vectors keyed by name (notably Qdrant gRPC vectors)
- Convention: the unnamed Qdrant vector is stored under key
"default"
Property vectors still exist (and are useful for Cypher compatibility):
node.Properties[propertyKey]may contain a vector array (e.g.n.embedding = [..])
Two vector-search execution paths
NornicDB has two distinct vector-search entrypoints that share the same core service, but intentionally preserve different semantics.
1) Indexed search (search.Service.Search / VectorSearchCandidates)
Used by:
- HTTP
/nornicdb/search(hybrid search / RRF) - Qdrant gRPC search endpoints (compatibility layer)
- MCP flows that call
DB.HybridSearch()
Implementation:
- Core:
pkg/search/search.go - Indexing:
(*search.Service).IndexNode() - Result cache:
Search()results are cached by query + options (LRU, TTL 5m); cache is invalidated onIndexNode/RemoveNode. All call paths (HTTP, Cypher, MCP) share this cache so repeated identical searches are fast.
Index entry IDs:
- Chunk embeddings:
ChunkEmbeddings[0]is indexed under the node ID itself (the main entry); each later chunk gets its own entry. - Each named embedding and each vector-shaped property gets its own entry.
- Entry keys are built and decoded only by
pkg/search/vector_entry_key.go. The encoding is injective: an entry key always names exactly one node, whatever characters the node ID, vector name or property key contain, so a node's entries can never collide with another node's. Search decodes each result's owner once, when it collapses entries to nodes; nothing parses IDs. - Persisted vector stores and ANN graphs record the key format. A store saved with an older format is rebuilt from storage on startup.
This means NamedEmbeddings are indexed (they are not “storage-scan only”).
2) Cypher vector procedure (db.index.vector.queryNodes)
Used by:
- Cypher procedure
CALL db.index.vector.queryNodes(...)
Implementation:
- Cypher layer:
pkg/cypher/call_vector.go(callDbIndexVectorQueryNodes) - Core:
pkg/search/vector_query_spec.go((*search.Service).VectorQueryNodes)
Important behavior:
- Uses Cypher vector index metadata (label/property/similarity) but executes through
search.Service - Today,
VectorQueryNodesis intentionally scan-based to preserve Cypher semantics (embedding precedence + filters)
Embedding selection order (per node):
node.NamedEmbeddings[index.property](or"default"when the index has no property)node.Properties[index.property]if it contains a vector arraynode.ChunkEmbeddings[0..N]
This path exists for Neo4j compatibility, but it is inherently O(N) over nodes in storage. The design keeps this complexity isolated behind search.Service, so future optimizations can be made without changing the Cypher interface.
Qdrant gRPC mapping (collections and points)
The Qdrant gRPC layer stores points as nodes:
- Qdrant collection → NornicDB database (namespace).
- Storage isolation is done via
multidb.DatabaseManager+storage.NamespacedEngine. - The Qdrant gRPC layer opens a namespaced engine for every request:
dbManager.GetStorage(collectionName). - Collection metadata is persisted inside that database as a required node:
- Node ID:
_collection_meta - Label:
_CollectionMeta - Properties:
{dimensions: int, distance: int32(qdrant.Distance), schema_version: 1}
- Node ID:
- Implementation:
pkg/qdrantgrpc/collection_store.go
- Storage isolation is done via
- Qdrant point → a node inside the collection/database namespace:
- Node ID:
qdrant:point:<raw-id>(collection name is not embedded; namespace already scopes it) - Labels:
QdrantPoint,Point - Payload →
node.Properties(with internal_qdrant_*keys stripped from API output) - Vectors →
Node.NamedEmbeddings(single unnamed vector stored as"default") - Implementation:
pkg/qdrantgrpc/points_service.go
- Node ID:
Qdrant vectors do not overwrite NornicDB-managed embeddings because managed embeddings are stored in ChunkEmbeddings.
Drop behavior (why DROP DATABASE is fast)
Because collections are databases, deleting a collection is a database drop:
DROP DATABASE <name>/DatabaseManager.DropDatabase(ctx, name)deletes the namespace prefix<name>:viastorage.Engine.DeleteByPrefix.- For Badger-backed engines,
DeleteByPrefixis optimized to use Badger's prefix drop for db-scoped keyspaces and targeted cleanup for secondary indexes.
Why this split matters
Separating NamedEmbeddings and ChunkEmbeddings enables:
- No clobbering: managed embeddings and client vectors can coexist on a node
- Clear ownership: Qdrant gRPC owns
NamedEmbeddings, embedding pipeline ownsChunkEmbeddings - Flexible query semantics:
- Cypher can stay Neo4j-compatible via property vectors and scans
- HTTP/gRPC can stay fast via the indexed
search.Servicepath