Cross-engine operations and unified entity management for Neumann. Provides a single interface for queries that span relational, graph, and vector engines with async-first design and thread safety inherited from TensorStore.
- Cross-Engine Abstraction: Single interface for operations spanning multiple engines
- Unified Entities: Entities can have relational fields, graph connections, and embeddings
- Composable Queries: Combine vector similarity with graph connectivity
- Async-First: All cross-engine operations support async execution
- Thread Safety: Inherits from underlying engines via TensorStore
+------------------+
| UnifiedEngine |
+------------------+
|
+------------------+------------------+
| | |
v v v
+---------------+ +---------------+ +---------------+
| Relational | | Graph | | Vector |
| Engine | | Engine | | Engine |
+---------------+ +---------------+ +---------------+
| | |
+------------------+------------------+
|
+------v------+
| TensorStore |
+-------------+
All engines share the same TensorStore instance, enabling cross-engine queries without data duplication.
sequenceDiagram
participant Client
participant UnifiedEngine
participant VectorEngine
participant GraphEngine
participant TensorStore
Client->>UnifiedEngine: create_entity("user:1", fields, embedding)
UnifiedEngine->>VectorEngine: set_entity_embedding("user:1", embedding)
VectorEngine->>TensorStore: put("user:1", TensorData{_embedding: ...})
UnifiedEngine->>TensorStore: get("user:1")
TensorStore-->>UnifiedEngine: TensorData
UnifiedEngine->>TensorStore: put("user:1", TensorData{fields + _embedding})
UnifiedEngine-->>Client: Ok(())
| Type | Description |
|---|---|
UnifiedEngine |
Main entry point for cross-engine operations |
UnifiedResult |
Query result containing description and items |
UnifiedItem |
Single item with source, id, data, embedding, and score |
UnifiedError |
Error type wrapping engine-specific errors |
FindPattern |
Pattern for FIND queries (Nodes or Edges) |
DistanceMetric |
Similarity metric (Cosine, Euclidean, DotProduct) |
EntityInput |
Tuple type for batch operations: (key, fields, embedding) |
Unified |
Trait for converting engine types to UnifiedItem |
FilterCondition |
Re-exported from vector_engine for filtered search |
FilterValue |
Re-exported from vector_engine for filter values |
VectorCollectionConfig |
Re-exported from vector_engine for collection config |
pub struct UnifiedItem {
pub source: String, // "relational", "graph", "vector", or combined
pub id: String, // Entity key
pub data: HashMap<String, String>, // Entity fields
pub embedding: Option<Vec<f32>>, // Optional embedding
pub score: Option<f32>, // Similarity score if applicable
}The source field indicates which engine(s) produced the result:
"graph"- Result from graph operations (nodes, edges)"vector"- Result from vector similarity search"unified"- Result from cross-engine entity retrieval"vector+graph"- Result fromfind_similar_connected(similarity + connectivity)"graph+vector"- Result fromfind_neighbors_by_similarity(connectivity + similarity)
| Variant | Cause |
|---|---|
RelationalError |
Error from relational engine |
GraphError |
Error from graph engine |
VectorError |
Error from vector engine |
NotFound |
Entity not found |
InvalidOperation |
Invalid operation attempted |
Error conversion is automatic via From implementations:
impl From<graph_engine::GraphError> for UnifiedError {
fn from(e: graph_engine::GraphError) -> Self {
UnifiedError::GraphError(e.to_string())
}
}
impl From<vector_engine::VectorError> for UnifiedError {
fn from(e: vector_engine::VectorError) -> Self {
UnifiedError::VectorError(e.to_string())
}
}
impl From<relational_engine::RelationalError> for UnifiedError {
fn from(e: relational_engine::RelationalError) -> Self {
UnifiedError::RelationalError(e.to_string())
}
}Unified entities use reserved field prefixes in TensorData to store
cross-engine data within a single key-value entry:
| Field | Type | Description |
|---|---|---|
_out |
Pointers(Vec<String>) |
Outgoing edge keys |
_in |
Pointers(Vec<String>) |
Incoming edge keys |
_embedding |
Vector(Vec<f32>) or Sparse(SparseVector) |
Embedding vector |
_label |
Scalar(String) |
Entity type/label |
_type |
Scalar(String) |
Discriminator ("node", "edge", "row") |
_id |
Scalar(Int) |
Numeric entity ID |
_from |
Scalar(String) |
Edge source key |
_to |
Scalar(String) |
Edge target key |
_edge_type |
Scalar(String) |
Edge type |
_directed |
Scalar(Bool) |
Whether edge is directed |
_table |
Scalar(String) |
Table name for relational rows |
Key: "user:alice"
TensorData:
_embedding: Vector([0.1, 0.2, 0.3, 0.4])
_out: Pointers(["edge:follows:1", "edge:likes:2"])
_in: Pointers(["edge:follows:3"])
name: Scalar(String("Alice"))
role: Scalar(String("admin"))
Key: "edge:follows:1"
TensorData:
_type: Scalar(String("edge"))
_from: Scalar(String("user:alice"))
_to: Scalar(String("user:bob"))
_edge_type: Scalar(String("follows"))
_directed: Scalar(Bool(true))
Embeddings are automatically stored in sparse format when >50% of values are zero:
fn should_use_sparse(vector: &[f32]) -> bool {
if vector.is_empty() {
return false;
}
let nnz = vector.iter().filter(|&&v| v.abs() > 1e-6).count();
// Sparse if nnz <= len/2
nnz * 2 <= vector.len()
}use tensor_unified::UnifiedEngine;
use tensor_store::TensorStore;
// Create with new store
let engine = UnifiedEngine::new();
// Create with shared store
let store = TensorStore::new();
let engine = UnifiedEngine::with_store(store);
// Create with existing engines
let engine = UnifiedEngine::with_engines(store, relational, graph, vector);pub struct UnifiedEngine {
store: TensorStore,
relational: Arc<RelationalEngine>,
graph: Arc<GraphEngine>,
vector: Arc<VectorEngine>,
}The Arc wrappers enable:
- Thread-safe sharing across async tasks
- Zero-copy cloning of the engine
- Independent engine access when needed
use std::collections::HashMap;
// Create an entity with fields and optional embedding
let mut fields = HashMap::new();
fields.insert("name".to_string(), "Alice".to_string());
fields.insert("role".to_string(), "admin".to_string());
engine.create_entity(
"user:1",
fields,
Some(vec![0.1, 0.2, 0.3, 0.4]) // Optional embedding
).await?;Internal flow:
flowchart TD
A[create_entity] --> B{Has embedding?}
B -->|Yes| C[VectorEngine::set_entity_embedding]
C --> D[Store to TensorData._embedding]
B -->|No| E[Get existing TensorData or new]
D --> E
E --> F[For each field]
F --> G[Set field as TensorValue::Scalar]
G --> H[TensorStore::put]
// Connect entities via graph edge
let edge_id = engine.connect_entities("user:1", "user:2", "follows").await?;Edge creation updates three TensorData entries:
- Creates new edge entry with
_from,_to,_edge_type,_directed - Adds edge key to source entity's
_outfield - Adds edge key to target entity's
_infield
// Get entity with all data and embedding
let item = engine.get_entity("user:1").await?;
println!("Fields: {:?}", item.data);
println!("Embedding: {:?}", item.embedding);Gotcha: Returns UnifiedError::NotFound if the entity has neither fields
nor embedding.
Find entities similar to a query that are also connected via graph edges:
flowchart LR
A[Query Entity] --> B[Get embedding]
B --> C[Vector search top_k*2]
C --> D[Get connected neighbors]
D --> E[HashSet intersection]
E --> F[Take top_k]
F --> G[Return UnifiedItems]
// Find entities similar to query AND connected to target
let results = engine.find_similar_connected(
"user:1", // Query entity (uses its embedding)
"hub:main", // Find entities connected to this
10 // Top-k results
).await?;Algorithm details:
- Retrieves embedding from
query_keyviaVectorEngine::get_entity_embedding - Searches for top
k*2similar entities (over-fetches for filtering) - Gets neighbors of
connected_toviaGraphEngine::get_entity_neighbors - Builds HashSet of connected neighbors for O(1) lookup
- Filters similar results to only those in the neighbor set
- Returns top-k results with source
"vector+graph"
Edge case: If query_key has no embedding, returns VectorError::NotFound.
Enhanced version that combines vector similarity, graph connectivity, and metadata filtering:
use vector_engine::{FilterCondition, FilterValue};
// Build a filter for metadata
let filter = FilterCondition::Eq(
"category".to_string(),
FilterValue::String("article".to_string())
);
// Find entities similar to query, connected to hub, matching filter
let results = engine.find_similar_connected_filtered(
"user:1", // Query entity (uses its embedding)
"hub:main", // Find entities connected to this
Some(&filter), // Optional metadata filter
10 // Top-k results
).await?;Algorithm:
- Gets query embedding from
query_key - Gets connected neighbor keys from graph
- Builds combined filter:
key IN neighbors AND user_filter - Uses pre-filter strategy for high selectivity
- Returns filtered results with source
"vector+graph"
The filtered version eliminates post-processing by pushing filters into the vector search, improving performance for selective queries.
Find graph neighbors sorted by similarity to a query vector:
flowchart LR
A[Entity Key] --> B[Get neighbors via graph]
B --> C[For each neighbor]
C --> D[Get embedding]
D --> E{Dimension match?}
E -->|Yes| F[Compute cosine similarity]
E -->|No| G[Skip]
F --> H[Collect results]
H --> I[Sort by score desc]
I --> J[Truncate to top_k]
// Find neighbors of an entity sorted by similarity to a vector
let results = engine.find_neighbors_by_similarity(
"user:1", // Entity to get neighbors of
&[0.1, 0.2, 0.3, 0.4], // Query vector
10 // Top-k results
).await?;Algorithm details:
- Gets all neighbors (both directions) via
GraphEngine::get_entity_neighbors - For each neighbor:
- Attempts to get embedding via
VectorEngine::get_entity_embedding - Skips if no embedding or dimension mismatch
- Computes cosine similarity with query vector
- Attempts to get embedding via
- Sorts results by score descending
- Truncates to top-k
- Returns results with source
"graph+vector"
Gotcha: Neighbors without embeddings are silently skipped.
The unified entity storage methods provide a streamlined API for storing entity fields as vector metadata, eliminating double-storage overhead.
use std::collections::HashMap;
let mut fields = HashMap::new();
fields.insert("title".to_string(), "Introduction to Rust".to_string());
fields.insert("author".to_string(), "Alice".to_string());
// Store entity with fields as vector metadata
engine.create_entity_unified(
"doc:1",
fields,
Some(vec![0.1, 0.2, 0.3, 0.4])
).await?;
// Without embedding, stores to TensorStore only
engine.create_entity_unified("doc:2", fields, None).await?;When an embedding is provided, fields are stored as vector metadata alongside the embedding. This enables filtered search without requiring a separate storage lookup.
// Get entity with fields from vector metadata
let item = engine.get_entity_unified("doc:1").await?;
println!("Title: {:?}", item.data.get("title"));
println!("Embedding: {:?}", item.embedding);The retrieval first attempts to load from vector metadata. If not found, it falls back to the standard TensorStore lookup.
Collections provide type-based organization for entities, enabling scoped searches and dimension enforcement.
use vector_engine::VectorCollectionConfig;
use std::collections::HashMap;
// Create a collection for documents
let config = VectorCollectionConfig::default()
.with_dimension(768)
.with_metric(DistanceMetric::Cosine);
engine.create_entity_collection("documents", config)?;
// Store entity in collection
let mut fields = HashMap::new();
fields.insert("title".to_string(), "ML Paper".to_string());
engine.create_entity_in_collection(
"documents",
"paper:1",
fields,
vec![0.1; 768]
).await?;use vector_engine::FilterCondition;
// Basic search in collection
let results = engine.find_similar_in_collection(
"documents",
&query_embedding,
None, // No filter
10
).await?;
// Filtered search in collection
let filter = FilterCondition::Eq("author".to_string(), "Alice".into());
let results = engine.find_similar_in_collection(
"documents",
&query_embedding,
Some(&filter),
10
).await?;// List all entity collections
let collections = engine.list_entity_collections();
// Delete a collection
engine.delete_entity_collection("documents")?;Collections ensure entity isolation:
- Each collection has its own key namespace
- Dimension mismatches are rejected per-collection config
- Searches only see entities within the specified collection
- Deleting a collection removes all entities in it
// Find all nodes with optional label filter
let nodes = engine.find_nodes(Some("person"), None).await?;
// Find all edges with optional type filter
let edges = engine.find_edges(Some("follows"), None).await?;
// Find with pattern and limit
let pattern = FindPattern::Nodes { label: Some("document".to_string()) };
let result = engine.find(&pattern, Some(10)).await?;The find_nodes and find_edges methods scan the TensorStore for matching
entities:
fn scan_nodes(&self, label_filter: Option<&str>) -> Result<Vec<Node>> {
let keys = self.store.scan("node:"); // Prefix scan
for key in keys {
// Filter out edge lists (node:123:out, node:123:in)
if key.contains(":out") || key.contains(":in") {
continue;
}
// Parse node ID from key "node:{id}"
if let Some(id_str) = key.strip_prefix("node:") {
if let Ok(id) = id_str.parse::<u64>() {
// Fetch and optionally filter by label
}
}
}
}Conditions are evaluated against node/edge properties:
| Condition | Node Fields | Edge Fields |
|---|---|---|
Eq("id", ...) |
Matches node.id |
Matches edge.id |
Eq("label", ...) |
Matches node.label |
N/A |
Eq("type", ...) |
N/A | Matches edge.edge_type |
Eq("edge_type", ...) |
N/A | Matches edge.edge_type (alias) |
Eq("from", ...) |
N/A | Matches edge.from |
Eq("to", ...) |
N/A | Matches edge.to |
Eq(property, ...) |
Matches node.properties[property] |
Matches edge.properties[property] |
And(a, b) |
Both must match | Both must match |
Or(a, b) |
Either must match | Either must match |
| Other conditions | Returns true (pass-through) |
Returns true (pass-through) |
Gotcha: Conditions other than Eq, And, Or return true (not yet
implemented for graph entities).
// Store multiple embeddings
let items = vec![
("doc1".to_string(), vec![0.1, 0.2, 0.3]),
("doc2".to_string(), vec![0.4, 0.5, 0.6]),
];
let count = engine.embed_batch(items).await?;
// Create multiple entities
let entities: Vec<EntityInput> = vec![
("e1".to_string(), HashMap::from([("name".to_string(), "A".to_string())]), None),
("e2".to_string(), HashMap::from([("name".to_string(), "B".to_string())]), Some(vec![0.1, 0.2])),
];
let count = engine.create_entities_batch(entities).await?;Note: Batch operations process sequentially (not parallel). Failed individual operations are counted as failures but don't abort the batch.
Types implementing the Unified trait can be converted to UnifiedItem:
pub trait Unified {
fn as_unified(&self) -> UnifiedItem;
fn source_engine(&self) -> &'static str;
fn unified_id(&self) -> String;
}Implemented for:
graph_engine::Node- Converts label and properties to data fieldsgraph_engine::Edge- Converts from, to, type, and properties to data fieldsvector_engine::SearchResult- Converts key and score
impl Unified for Node {
fn as_unified(&self) -> UnifiedItem {
let mut item = UnifiedItem::new("graph", self.id.to_string());
item.set("label", &self.label);
for (k, v) in &self.properties {
item.set(k.clone(), format!("{:?}", v)); // Debug format for PropertyValue
}
item
}
fn source_engine(&self) -> &'static str { "graph" }
fn unified_id(&self) -> String { self.id.to_string() }
}
impl Unified for SearchResult {
fn as_unified(&self) -> UnifiedItem {
UnifiedItem::new("vector", &self.key).with_score(self.score)
}
fn source_engine(&self) -> &'static str { "vector" }
fn unified_id(&self) -> String { self.key.clone() }
}Cross-engine operations are exposed via the query language:
-- Create entity with fields and embedding
ENTITY CREATE 'user:1' {name: 'Alice', role: 'admin'} EMBEDDING [0.1, 0.2, 0.3]
-- Create entity with fields only
ENTITY CREATE 'user:2' {name: 'Bob'}
-- Connect entities
ENTITY CONNECT 'user:1' -> 'user:2' : follows-- Find similar entities that are also connected to a hub
SIMILAR 'query:key' CONNECTED TO 'hub:entity' LIMIT 10
-- Find neighbors sorted by similarity
NEIGHBORS 'entity:key' BY SIMILAR [0.1, 0.2, 0.3] LIMIT 10QueryRouter integrates with UnifiedEngine for cross-engine operations. When
created with with_shared_store(), the router automatically initializes an
internal UnifiedEngine:
classDiagram
class QueryRouter {
-relational: Arc~RelationalEngine~
-graph: Arc~GraphEngine~
-vector: Arc~VectorEngine~
-unified: Option~UnifiedEngine~
-hnsw_index: Option~HNSWIndex~
+with_shared_store(store) QueryRouter
+unified() Option~UnifiedEngine~
+find_similar_connected()
+find_neighbors_by_similarity()
}
class UnifiedEngine {
-store: TensorStore
-relational: Arc~RelationalEngine~
-graph: Arc~GraphEngine~
-vector: Arc~VectorEngine~
}
QueryRouter --> UnifiedEngine : contains
QueryRouter --> RelationalEngine : shares Arc
QueryRouter --> GraphEngine : shares Arc
QueryRouter --> VectorEngine : shares Arc
UnifiedEngine --> RelationalEngine : shares Arc
UnifiedEngine --> GraphEngine : shares Arc
UnifiedEngine --> VectorEngine : shares Arc
use query_router::QueryRouter;
use tensor_store::TensorStore;
// Create router with shared store - this initializes UnifiedEngine
let store = TensorStore::new();
let router = QueryRouter::with_shared_store(store);
// Verify UnifiedEngine is available
assert!(router.unified().is_some());
// Cross-engine Rust API methods delegate to UnifiedEngine
let results = router.find_neighbors_by_similarity("entity:1", &[0.1, 0.2], 10)?;
let results = router.find_similar_connected("query:1", "hub:1", 5)?;
// Query language commands also use the integrated engines
router.execute_parsed("ENTITY CREATE 'doc:1' {title: 'Hello'} EMBEDDING [0.1, 0.2]")?;
router.execute_parsed("ENTITY CONNECT 'user:1' -> 'doc:1' : authored")?;
router.execute_parsed("SIMILAR 'query:doc' CONNECTED TO 'user:1' LIMIT 5")?;When QueryRouter has an HNSW index, find_similar_connected uses it instead of
brute-force search:
// Use HNSW index if available, otherwise fall back to brute-force
let similar = if let Some((ref index, ref keys)) = self.hnsw_index {
self.vector.search_with_hnsw(index, keys, &query_embedding, top_k * 2)
} else {
self.vector.search_entities(&query_embedding, top_k * 2)
};| Operation | Complexity | Notes |
|---|---|---|
create_entity |
O(1) | Single store put + optional embedding |
connect_entities |
O(1) | Three store operations (edge + 2 entity updates) |
get_entity |
O(1) | Single store get + optional embedding lookup |
find_similar_connected |
O(k log n) | HNSW search + graph intersection |
find_similar_connected (brute) |
O(n) | Linear scan when no HNSW index |
find_similar_connected_filtered |
O(m) | Pre-filter search, m = matching keys |
create_entity_unified |
O(1) | Single store with metadata |
get_entity_unified |
O(1) | Metadata lookup with fallback |
create_entity_in_collection |
O(1) | Collection-scoped store |
find_similar_in_collection |
O(c) | c = collection size |
find_neighbors_by_similarity |
O(d * k) | Neighbor fetch + k similarity computations |
find_nodes |
O(n) | Full scan with prefix filter |
find_edges |
O(e) | Full scan with prefix filter |
embed_batch |
O(b) | Sequential embedding storage |
create_entities_batch |
O(b) | Sequential entity creation |
Where:
- n = number of entities with embeddings
- d = average degree (number of neighbors)
- k = top-k results requested
- e = number of edges
- b = batch size
From tensor_unified_bench.rs:
| Operation | 10 items | 100 items | 1000 items |
|---|---|---|---|
create_entity |
~50us | ~500us | ~5ms |
embed_batch |
~30us | ~300us | ~3ms |
find_nodes |
~10us | ~100us | ~1ms |
UnifiedItem::new |
~50ns | --- | --- |
UnifiedItem::with_data |
~200ns | --- | --- |
UnifiedEngine is thread-safe via:
Arc<VectorEngine>,Arc<GraphEngine>,Arc<RelationalEngine>- All underlying engines share thread-safe TensorStore (DashMap)
- No lock poisoning (parking_lot semantics)
impl Clone for UnifiedEngine {
fn clone(&self) -> Self {
Self {
store: self.store.clone(), // Arc<DashMap> clone
relational: Arc::clone(&self.relational),
graph: Arc::clone(&self.graph),
vector: Arc::clone(&self.vector),
}
}
}Safe concurrent patterns:
- Multiple readers on same entity
- Multiple writers on different entities
- Mixed reads/writes (DashMap shard locking)
Gotcha: Concurrent writes to the same entity may interleave fields. Use transactions for atomicity.
UnifiedEngine uses the configuration of its underlying engines:
TensorStore: Storage configurationVectorEngine: HNSW index parameters, similarity metricsGraphEngine: Graph traversal settingsRelationalEngine: Table and index configuration
Use prefixed keys to distinguish entity types:
"user:123" // User entities
"doc:456" // Document entities
"hub:main" // Hub/aggregate entities
"edge:follows:1" // Edge entities (auto-generated)Ensure consistent embedding dimensions across entities:
// Good: All entities use 384-dimensional embeddings
engine.create_entity("doc:1", fields, Some(vec![0.0; 384])).await?;
engine.create_entity("doc:2", fields, Some(vec![0.0; 384])).await?;
// Bad: Dimension mismatch causes similarity search to skip entities
engine.create_entity("doc:1", fields, Some(vec![0.0; 384])).await?;
engine.create_entity("doc:2", fields, Some(vec![0.0; 768])).await?; // Different dimension!For find_similar_connected:
- Build HNSW index for large vector sets (>5000 entities)
- Ensure
connected_toentity has edges (empty neighbors returns empty results) - Request
top_k * 2internally to account for filtering
For find_neighbors_by_similarity:
- Ensure neighbors have embeddings (no embedding = skipped)
- Use same dimension for query vector as stored embeddings
- Consider degree distribution (high-degree nodes = more similarity computations)
| Module | Relationship |
|---|---|
tensor_store |
Shared storage backend, provides TensorData and fields constants |
relational_engine |
Relational data, conditions for filtering |
graph_engine |
Graph connectivity, entity edges, neighbor queries |
vector_engine |
Embeddings, similarity search, HNSW index, FilterCondition, FilterValue, VectorCollectionConfig |
query_router |
Query execution, language integration, HNSW optimization, re-exports filter types |
tensor_store: Core storagerelational_engine: Table operationsgraph_engine: Graph operationsvector_engine: Vector searchtokio: Async runtime (multi-threaded)futures: Async utilitiesserde: Serialization for results and itemsserde_json: JSON output forUnifiedResult
From examples/code_search.rs:
use std::collections::HashMap;
use tensor_unified::UnifiedEngine;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let engine = UnifiedEngine::new();
// Store functions with embeddings representing semantic meaning
let mut props = HashMap::new();
props.insert("type".to_string(), "function".to_string());
props.insert("language".to_string(), "rust".to_string());
// "process_data" - embedding represents data processing semantics
engine.create_entity(
"func:process_data",
props.clone(),
Some(vec![1.0, 0.9, 0.0, 0.0])
).await?;
// "validate_input" - embedding represents validation semantics
engine.create_entity(
"func:validate_input",
props.clone(),
Some(vec![0.0, 0.1, 0.9, 0.9])
).await?;
// Create call graph relationship
engine.connect_entities(
"func:process_data",
"func:validate_input",
"CALLS"
).await?;
// Find functions similar to "data processing" that call validate_input
let results = engine.find_similar_connected(
"func:process_data", // Query by this function's embedding
"func:validate_input", // Must be connected to validation
5
).await?;
for item in results {
println!("Found: {} (Score: {:.4})", item.id, item.score.unwrap_or(0.0));
}
Ok(())
}The contraction module fuses graph adjacency, vector similarity, and
relational interactions into a single algebraic scoring expression:
score = (G[x,:] ⊙ s)^T R (shape-safe: (1×n ⊙ 1×n)^T · n×m → 1×m)
where G is the graph adjacency row for source entity x, s is the cosine-similarity vector between x and its neighbors, ⊙ is the element-wise (Hadamard) product, and R is the neighbor-to-item interaction matrix from a relational table.
Given a source entity (e.g. a user):
- Adjacency — gather the source's graph neighbors with edge weights,
optionally filtered by edge type (e.g. only
"FRIEND"edges) and direction (outgoing, incoming, or both). - Similarity — compute cosine similarity between the source's embedding and each neighbor's embedding. Neighbors without embeddings are skipped.
- Hadamard product — fuse adjacency and similarity into a single weight
per neighbor:
w[i] = adj[i] * sim[i]. Non-finite values are skipped. - Normalization (optional) — L1-normalize the weight vector (
Σ|w_i|) to control magnitude. Uses absolute values to handle negative similarities. - Contraction — multiply the weight vector by the interaction matrix: for each neighbor, distribute its weight to every item it interacted with.
- Post-processing — exclude already-owned items, apply a category mask, per-item normalization (divide by contributor count), filter non-finite scores, and truncate to top-k with deterministic tie-breaking (score descending, then key ascending).
Graph: alice --FRIEND--> bob, carol, dave
Embeddings:
alice = [1, 0, 0]
bob = [0.9, 0.1, 0] cos(alice, bob) ≈ 0.994
carol = [0.5, 0.5, 0] cos(alice, carol) ≈ 0.707
dave = [0, 1, 0] cos(alice, dave) = 0.000
Purchases table:
bob -> {book, pen}
carol -> {pen, laptop}
dave -> {phone}
Contraction scores:
pen = 0.994 + 0.707 = 1.701 (from bob + carol)
book = 0.994 (from bob)
laptop = 0.707 (from carol)
phone = 0.000 (from dave — orthogonal embedding)
Items are ranked by score descending: pen > book > laptop > phone.
| Type | Description |
|---|---|
AdjacencyVec |
HashMap<String, f64> — neighbor key to edge weight |
SimilarityVec |
HashMap<String, f64> — neighbor key to cosine similarity |
InteractionMap |
HashMap<String, HashSet<String>> — intermediary to item set |
ContractionConfig |
Direction, normalization, edge type filter, top-k |
ScoredItem |
Item key, score, and contributor count |
ContractionResult |
Sorted items, weight norm, and excluded count |
let config = ContractionConfig {
direction: GraphDirection::Symmetric, // or Outgoing / Incoming
normalization: Normalization::TotalWeight, // or None / PerItem
edge_type: Some("FRIEND".into()), // None = all edge types
exclude_owned: true, // remove source's own items
top_k: 10,
};Normalization strategies:
None— raw scores, no normalization.TotalWeight— divide each weight byΣ|w_i|(L1 norm). Prevents sources with many high-similarity neighbors from dominating.PerItem— divide each item's final score by its contributor count. Reduces popularity bias for widely-consumed items.
UnifiedEngine::cross_modal_contraction() gathers all three data sources and
calls the pure contract() function:
let result = engine.cross_modal_contraction(
"alice", // source entity key
"purchases", // relational table
"buyer", // column identifying the intermediary
"item", // column identifying the item
&config,
None, // optional category mask
).await?;
for item in &result.items {
println!("{}: {:.3} ({} contributors)",
item.item_key, item.score, item.contributors);
}Schema validation: the adapter checks that the table and columns exist, and
that column types are key-compatible (String, Int, or Float). Bool,
Bytes, and Json columns are rejected with InvalidOperation.
Edge weight extraction: if an edge has a "weight" property with a numeric
value, that value is used as the adjacency weight. Otherwise the edge is treated
as unweighted (1.0).
Performance: the interaction table is scanned once via
select(table, Condition::True), giving O(|table|) cost. This materializes the
full table in memory, so callers with very large tables should ensure
max_query_result_rows is unset or adequate.