
Graph-Native Data Structures in C#, Part 7: Bipartite Graphs & Collaborative Filtering in NebulaGraph
In this series finale we build a user and product recommendation engine on NebulaGraph using bipartite graph modelling and two hop collaborative filtering, including scored ranking, cache invalidation, and where hybrid vector and graph retrieval goes next.
This is Part 7 and the finale of the Graph-Native Data Structures in C# series. If you're arriving fresh, Part 1 covers graph fundamentals and the vocabulary we've been building on throughout. The recommendation engine pattern we're closing out here first appeared in the ArangoDB to NebulaGraph migration article. Here we build it properly, step by step.
The Bipartite Structure
A bipartite graph partitions vertices into two disjoint sets where edges only cross between sets, never within. For a recommendation engine that's a natural fit: users on one side, products on the other, and interactions as the only edges. No user-to-user edges exist. No product-to-product edges exist. The graph is bipartite by design.
NebulaGraph doesn't enforce bipartite structure at the schema level, it is kept that way by application convention. The engine won't stop you creating a purchased edge between two users. That discipline lives in your insertion code: always source from a user: VID, always target a product: VID.
Our schema looks like this in nGQL DDL:
CREATE TAG user(handle STRING);
CREATE TAG product(sku STRING, name STRING, active BOOL);
CREATE EDGE purchased(ts INT);
CREATE EDGE viewed(ts INT, weight DOUBLE);Vertex IDs use the STRING type with a namespace prefix: 'user:alice', 'product:SKU-42'. NebulaGraph supports string VIDs up to 256 bytes, and the prefix convention makes VID type unambiguous without reading tag properties, which pays off when you filter results in C#.
Modeling Interactions as Typed Edges
The critical modeling decision here is separate edge types for separate interaction semantics, not a single interaction edge with a type property. purchased and viewed are distinct edge types. This matters for two reasons.
First, it lets the GO statement traverse selectively, so you can run collaborative filtering on purchase signal alone, which is higher quality than view signal. Second, edge types participate in NebulaGraph's storage indexing differently from edge properties. Querying OVER purchased is cheaper than OVER interaction WHERE interaction.type == 'purchased'.
The weight DOUBLE on viewed carries a decay factor you populate at insertion time, so a view from yesterday scores differently from one six months ago. purchased doesn't need this because recency is captured by the ts timestamp and purchases are inherently stronger signal.
The Two-Hop Collaborative Filtering Traversal
The core idea: from a target user, follow purchased edges to the products they own (hop 1). From those products, follow purchased edges back to other users who bought the same products (still hop 1, but reversed). From those peer users, follow purchased edges forward again to everything they've bought (hop 2). What you get at hop 2 is a raw candidate set for recommendation.
BIDIRECT on a bipartite graph gives you exactly this in a single GO statement. Because edges only ever run user → product, traversing bidirectionally means hop 1 reaches products (forward) and users (backward from the products' incoming edges) simultaneously. Two hops BIDIRECT equals the full collaborative filtering pattern with no second query.
GO 2 STEPS FROM 'user:alice' OVER purchased BIDIRECT
WHERE $$.product.active == true
YIELD DISTINCT dst(edge) AS vid, tags($$) AS vtags
| ORDER BY $-.vid ASC
| LIMIT 500A few mechanics to be explicit about. The GO statement uses a walk path type, so vertices and edges can be revisited. That means 'user:alice' can appear in intermediate results. The YIELD DISTINCT deduplicates final-hop destination VIDs, but intermediate walk paths aren't deduplicated. Your C# code needs to filter the originating user from results. Also, the WHERE $$.product.active == true filter applies to the destination vertex at each hop, and the $$ notation reads destination tag properties inline.
One production gotcha: a bestselling product might have millions of purchasers. A 2-hop traversal from any user who bought it fans out catastrophically. NebulaGraph v5.2 introduced a SAMPLE clause on the GO statement specifically to throttle super-node traversals, giving predictable latency without killing completeness entirely. For earlier versions, the LIMIT at the end is your blunt instrument.
Scoring and Ranking in C#
The graph layer returns raw candidate VIDs. Scoring happens in C#. The RecommendAsync method below handles deduplication, weighted scoring by interaction type, exclusion of owned items, and top-k cutoff.
public record Recommendation(string ProductVid, string ProductName, double Score);
public class RecommendationService
{
private const double PurchaseWeight = 2.0;
private const double ViewWeight = 0.5;
private readonly INebulaSessionPool _pool;
public RecommendationService(INebulaSessionPool pool) => _pool = pool;
public async Task<IReadOnlyList<Recommendation>> RecommendAsync(
string userHandle, int k, CancellationToken ct = default)
{
var userVid = $"user:{userHandle}";
await using var session = await _pool.GetSessionAsync(ct);
// Step 1: fetch owned products to exclude
var ownedResult = await session.ExecuteAsync(
$"GO 1 STEPS FROM '{userVid}' OVER purchased YIELD dst(edge) AS vid");
var owned = ownedResult.AsVertices()
.Select(v => v.Vid)
.ToHashSet(StringComparer.Ordinal);
// Step 2: 2-hop collaborative filtering
var candidateResult = await session.ExecuteAsync($"""
GO 2 STEPS FROM '{userVid}' OVER purchased BIDIRECT
WHERE $$.product.active == true
YIELD dst(edge) AS vid
""");
// Step 3: score by co-occurrence frequency
var scores = new Dictionary<string, double>(StringComparer.Ordinal);
foreach (var row in candidateResult.Rows)
{
var vid = row["vid"].AsString();
if (!vid.StartsWith("product:", StringComparison.Ordinal)) continue;
if (owned.Contains(vid)) continue;
scores.TryGetValue(vid, out var existing);
scores[vid] = existing + PurchaseWeight;
}
// Fetch names for top candidates before final cut
var topVids = scores
.OrderByDescending(kv => kv.Value)
.Take(k * 3) // over-fetch before name lookup
.Select(kv => kv.Key)
.ToList();
if (topVids.Count == 0) return Array.Empty<Recommendation>();
var vidList = string.Join(", ", topVids.Select(v => $"'{v}'"));
var nameResult = await session.ExecuteAsync(
$"FETCH PROP ON product {vidList} YIELD id(vertex) AS vid, properties(vertex).name AS name");
var nameMap = nameResult.Rows
.ToDictionary(r => r["vid"].AsString(), r => r["name"].AsString());
return scores
.Where(kv => nameMap.ContainsKey(kv.Key))
.OrderByDescending(kv => kv.Value)
.Take(k)
.Select(kv => new Recommendation(kv.Key, nameMap[kv.Key], kv.Value))
.ToList();
}
}The scoring here is co-occurrence frequency multiplied by the interaction weight constant. Each time a product VID appears in the 2-hop results it means one more peer user bought it, so one more unit of PurchaseWeight added to its score. If you also query over viewed, add rows weighted at ViewWeight. The originating user is excluded implicitly because their owned items are in the owned set; peer user VIDs are filtered by the StartsWith("product:") check.
Caching and Freshness
For small-to-medium graphs, live traversal on NebulaGraph is fast, with two hop queries finishing in single digit milliseconds under typical conditions. But as the user base grows, precomputing recommendations for your top-traffic users is the pragmatic move.
The standard pattern: a background worker runs RecommendAsync for active users and writes results to Redis with a TTL. On a new purchased event, invalidate that user's cache key immediately, because a purchase is a hard signal change that makes stale recommendations misleading.
public class PurchaseEventHandler
{
private readonly IDatabase _redis;
private readonly RecommendationService _svc;
public PurchaseEventHandler(IDatabase redis, RecommendationService svc)
{
_redis = redis;
_svc = svc;
}
public async Task HandleAsync(string userHandle, string productSku, CancellationToken ct)
{
// Invalidate stale recommendations immediately
await _redis.KeyDeleteAsync($"recs:{userHandle}");
// Optionally warm the cache synchronously for low-latency users
// (or defer to background worker for high-volume systems)
var recs = await _svc.RecommendAsync(userHandle, k: 20, ct);
var json = JsonSerializer.Serialize(recs);
await _redis.StringSetAsync(
$"recs:{userHandle}", json,
expiry: TimeSpan.FromHours(6));
}
}TTL guidance: 1 to 6 hours works for moderate purchase velocity. High-velocity catalogs (flash sales, gaming item drops) may need shorter TTLs or event-driven invalidation from a message bus. The key insight is that the invalidation event is the purchase itself, so your graph write and your cache eviction should happen in the same logical transaction boundary, even if they're not literally atomic.
Where to Go Next: Hybrid Vector-Graph Retrieval
Pure collaborative filtering has well-known limits: cold start for new users, popularity bias, inability to reason about product content similarity. The natural next step is combining graph traversal with semantic vector search.
NebulaGraph v5.2 introduced native hybrid retrieval: a single query can combine graph traversal, vector similarity lookup, and full-text keyword search without external engines. This is the foundation for a production RAG augmented recommender. Use the two hop collaborative filtering to generate candidates, then re-rank using vector embeddings of product descriptions stored natively in the graph. The vector data type and vector indexes landed in v5.0; v5.2 made them composable with traversal in one query.
That's a full article in itself and a natural follow-on from this series.
Series Recap
This finale closes the Graph-Native Data Structures in C# series. Across seven parts we've covered:
- Part 1. Graph fundamentals: adjacency lists, adjacency matrices, and when graphs beat relational models
- Parts 2 to 4. Core algorithms: BFS, DFS, shortest paths, and cycle detection implemented in C#
- Parts 5 and 6. NebulaGraph in practice: schema design, the nGQL GO statement, and multi-hop traversal patterns
- Part 7 (this article). Bipartite graphs, typed interaction edges, collaborative filtering, and a production-ready recommendation service
The thread running through all of it: graph problems don't simplify under a relational lens. Modeling your data natively as vertices and edges, and querying it with traversal semantics, gives you capabilities that no amount of JOIN optimization recovers. The recommendation engine here is a clean example: the two hop collaborative filter is four lines of nGQL; its SQL equivalent is a multi-level self-join that becomes unmaintainable at scale.
Build the graph. Traverse it. Rank in C#. Cache the results. Then upgrade to hybrid search when your users outgrow pure collaborative filtering.
Sources
- Build software better, together
- GO - NebulaGraph Database Manual
- nGQL cheatsheet - NebulaGraph Database Manual
- Overview - NebulaGraph Database Manual
- Oracle-guided Dynamic User Preference Modeling for Sequential Recommendation
- NebulaGraph Query Language (nGQL)
- Graph Query Language Comparison: Gremlin vs Cypher vs nGQL
- A Survey on Deep Neural Networks in Collaborative Filtering Recommendation Systems
Keep reading

September 28, 2026 · 7 min
Graph-Native Data Structures in C#, Part 6: DAGs and Dependency Graphs
Model a build system dependency graph in NebulaGraph, keep it acyclic from C# at insert time, get the execution order with Kahn's algorithm, and answer what is blocked right now. With real nGQL and C# code.
Read
July 16, 2026 · 6 min
Graph-Native Data Structures in C#, Part 4: Graphs and Adjacency with a Social Follow Network
Part 4 of the series models a directed social follow network in NebulaGraph, then surfaces neighbour queries, mutual connections, friend-of-friend suggestions, and super-node protection as typed C# async methods.
Read
July 13, 2026 · 7 min
Graph-Native Data Structures in C#, Part 3: Linked Lists and Sequences as Edges
Model append-only event streams and version chains as linked-list graphs in NebulaGraph, then walk, mutate, and guard them safely from C# using idempotent VIDs and native nGQL GO traversals.
Read