Back to blog
Recommendation Engine With Graph and Vector Search

This blog is written by AI for SEO

Recommendation Engine With Graph and Vector Search

HelixDB12 min read

Every recommendation system I have seen in a startup starts the same way. A vector DB serves "similar items". A graph store, or a pile of SQL joins, serves "people like you bought this". Then someone writes a sync job to keep the two roughly aligned, and that job becomes the thing that pages you at 2am.

This guide walks through the engineering steps to build a recommendation engine with graph and vector search, in the order you would actually do them. Candidate generation comes from the graph neighbourhood. Scoring comes from embedding similarity over that candidate set. Then you handle cold start and recency. We build the two-store version first so you can see exactly where the pain lives, then show how one engine removes it.

Prerequisites: you have items with embeddings (any embedding model you already use), you log user interactions with timestamps, and you can run Docker or work in-process from TypeScript. The examples use the HelixDB TypeScript SDK, but the steps apply to any stack.

One caveat up front. This guide makes no benchmark claims. Time handling is modelled by hand. The point is the architecture, not a speed contest.

Step 1: Model users, items and timestamped interaction edges

Start with the data model, because every later step reads from it. You need three things: user nodes, item nodes, and interaction edges between them.

Keep it boring. A User node has an id and whatever profile fields you filter on. An Item node has an id, a category, a title, and an embedding. An INTERACTED edge goes from user to item and carries kind (view, purchase, like), and a ts property holding a unix timestamp in seconds.

That ts property is the most important decision in the whole model. HelixDB has no built-in temporal modelling, so you model time yourself: a plain integer property on the edge, indexed, so range queries are cheap. Don't get clever with versioned edges or snapshot nodes on day one. A timestamp on the interaction is enough to answer "what did this user do in the last 30 days", which is the only temporal question a recommender asks.

In HelixDB the embedding is a property on the item node, not a separate system. That matters later. For now, decide two other things.

First, edge weights. A purchase should count for more than a view. Store kind on the edge and map it to a weight at scoring time, not at write time, so you can retune without rewriting data.

Second, tenant boundaries. If you serve several customers from one deployment, partition vector indexes per tenant from the start. Retrofitting that is miserable.

Write the schema down before you write code. If you can't draw users, items and timestamped edges on a whiteboard in under a minute, the model is too complicated.

Step 2: Build the two-store version first (and find the sync job)

Build the version most teams already run, because you need to feel where it hurts.

The two-store layout: a vector DB holds item_id -> embedding plus a few metadata fields. A graph store (or Postgres with join tables) holds users, items and interaction edges. The recommendation request looks like this:

  1. Ask the graph store for items that users-like-this-user interacted with.

  2. Ship that list of ids to the vector DB as a filter.

  3. Ask the vector DB to rank those ids by similarity to the user's taste vector.

  4. Fetch item details back from the graph store or your app DB.

That is three round trips before you render anything. It is also where the sync job appears. When an item is created, edited, deleted or re-embedded, two systems must agree. When it is deleted in one and not the other, you recommend a ghost. When the vector DB's metadata filter lags the graph, you filter on stale categories.

So you write a sync job. It listens to a queue, replays changes, retries on failure, and needs a reconciliation pass for drift. Now you own an ordering problem, a dead-letter queue and a dashboard.

The filter handoff is the second cost. Passing a few thousand candidate ids as an IN filter to a vector index is clumsy, and many vector stores handle large id filters poorly or cap them. You end up over-fetching top-k from the whole index and filtering afterwards, which throws away good results.

Write this version first, even as a prototype. It gives you a baseline for quality, and it shows you which seams the next steps remove.

Step 3: Generate candidates from the graph neighbourhood (co-interaction and 2-hop)

Candidate generation answers one question: which few hundred items are worth scoring at all? The graph answers it better than a vector index, because relationships encode behaviour that embeddings only approximate.

There are two traversals worth shipping.

Co-interaction ("bought together"): start at the seed item, walk back along INTERACTED edges to the users who touched it, then forward along their INTERACTED edges to other items. Count how often each item appears. Items that show up often among the same users are your candidates.

Two-hop from the user ("users like you"): start at the target user, walk to the items they interacted with, back to other users who interacted with those items, then forward to those users' items. Drop anything the target user has already seen.

Here is the shape of the first traversal with the TypeScript SDK. Treat the exact step names as a sketch and check them against the current docs before copying.

import { g, readBatch } from "@helix-db/helix-db";

// Sketch: co-interaction candidates for a seed item.
// Walk item -> users who interacted -> their other items.
const candidates = readBatch()
  .var("cands", g()
    .nWhere(/* item id == $seed */)
    /* in-edges INTERACTED -> users */
    /* out-edges INTERACTED -> items */
    /* dedupe, exclude seed, limit 500 */
  )
  .returning(["cands"]);

Cap the candidate set. A popular item can fan out to millions of two-hop neighbours. HelixDB documents that a candidate stream over 1,000,000 unique entities is a query error, and a traversal-scoped vector search is capped by the 800 effective result ceiling, applied after candidate intersection. Don't let those limits surprise you in production. Cap at 300 to 1,000 candidates in the traversal itself, ordered by co-occurrence count, and your later step stays cheap and predictable.

A hot item that every user touched is a bad candidate source. Downweight by item popularity, or skip items above a degree threshold. The graph is only useful if you stop it from returning the whole catalogue.

Step 4: Score the candidate set by vector similarity with pre-filtering

Now you rank. The candidates came from behaviour; the score comes from meaning. You take a query vector (the user's taste vector, or the seed item's embedding) and rank only the candidate set by cosine similarity.

In the two-store version this is the awkward handoff from Step 2: ship ids to the vector DB, hope the filter behaves, and merge scores back. In one engine it is a single query, because vector search can be scoped to a graph-filtered candidate set. That is vector pre-filtering: similarity runs over the things the traversal found, not over the whole index and then trimmed.

The practical difference is recall. Post-filtering fetches the global top-k first. If your seed item sits in a dense cluster, the global top 100 may contain three items your graph considers relevant, and you rank three results. Pre-filtering ranks inside the neighbourhood, so every candidate gets scored.

The shape of the query: traverse to candidates, then run a vector search restricted to that traversal's output, then return the top N. Same engine, same transaction, and the id list never leaves the database.

import { g, readBatch, VectorDistanceMetric } from "@helix-db/helix-db";

// Sketch: rank graph candidates by similarity to a query vector.
// Candidate traversal feeds the vector search directly (pre-filtering).

Blend the two signals explicitly. A simple, debuggable score is final = a * similarity + b * co_occurrence_norm. Start with a = 0.6, b = 0.4, normalise both to 0..1, and tune against your offline set in Step 7. Skip learned rerankers until this baseline is measured.

Two details from the vector index docs matter here. Vectors normalise to float32, and the index supports cosine, euclidean and manhattan distance. Use cosine unless your embedding model's documentation says otherwise. The vector must be a top-level property, and the index can be global or tenant-partitioned. If you are multi-tenant, partition it, so one customer's catalogue never competes with another's.

Step 5: Handle cold start with pure similarity

Cold start breaks the graph step by definition. A new item has no interaction edges, so the co-interaction traversal returns nothing. A new user has no history, so the two-hop traversal returns nothing. Recommendation systems that only know collaborative filtering show a blank row or fall back to "popular". That is a lazy default and users notice.

The fix is a branch in your request handler, not a second system.

New item: it has an embedding from the moment it is created, so it is findable by similarity immediately. To get it recommended alongside established items, include a small slice of pure-similarity results in every candidate set: the top 20 or so nearest items to the seed, with no graph filter. A fresh item shows up in the "similar to X" slice even with zero interactions, collects its first edges, and then graduates into the graph neighbourhood naturally.

New user: you have no taste vector yet, so use what you do have. A category the user picked at signup, the item they are currently viewing, or the search query they just typed. Embed that, run an unscoped vector search, and return it. After three or four interactions, switch on the two-hop traversal.

The rule: let the number of interaction edges decide the mix. Zero edges, 100% similarity. A handful, mostly similarity with a graph slice. Dozens, graph-led with similarity scoring. This is a threshold in application code.

Keep in mind the unrestricted vector search has its own documented ceiling of 800 effective results. That is far more than you will ever render in a recommendation row, so it shouldn't bite here, but know it exists before you design a "load more" feature on top of it.

Cold start is where one engine earns its keep operationally. Both branches hit the same store and the same item records, so there is no window where an item exists in the graph but not yet in the vector index.

Step 6: Add recency with time-range filters on interaction edges

A recommender with no sense of time recommends what was popular in 2022. Recency belongs in two places: which interactions count, and how much they count.

First, restrict the traversal. When you walk INTERACTED edges in Step 3, add a filter on the edge's ts property: only interactions from the last 30 days feed the co-interaction count. This is where HelixDB's time-range indexing pays off. Because ts is an indexed property, a range predicate doesn't scan every interaction a hot item has ever received. To be precise about the claim: HelixDB provides efficient time-range indexing, and you model the rest of time yourself.

Second, decay the weight. Inside the window, a view from yesterday should beat a view from three weeks ago. Compute w = base_weight * exp(-age_days / half_life) in application code after the traversal returns, with a half-life of 7 to 14 days as a starting point. Tune per surface: news-like content wants a short half-life, furniture wants a long one.

Third, consider two windows. A short window (7 days) for "trending with people like you", a long window (90 days) for stable taste. Run both traversals and blend. Because both are range-filtered reads on the same indexed property, the second one costs you one more query, not another data store.

One trap: item age and interaction age are different things. A new item with a burst of interactions yesterday is hot. An old item with the same burst is trending. If you want a freshness boost for new items, store created_at on the item node and filter or boost on it separately. Keep it off the edge timestamp.

For a deeper look at the mechanics, see how to query agent memory by time range without a full scan, which covers the same indexing approach for a different workload.

Step 7: Evaluate, then troubleshoot weak recommendations

Don't tune by eyeballing a demo account. Build an offline evaluation before you change a single weight.

The recipe: take interaction history, hold out each user's most recent interaction, generate recommendations from everything before it, and check whether the held-out item appears in your top 10. That gives you hit rate at 10. Add recall at 50 for the candidate stage alone, so you can tell whether the problem is generation or ranking. Split by user segment (new, light, heavy) because cold start problems hide inside averages.

Then read the failures. Pull 20 users whose held-out item was missed and look at what you did return. The pattern usually names the bug.

Symptom: the same popular items for everyone. Cause: hot-item fan-out in the traversal. Fix: downweight by item degree or cap per-item contribution.

Symptom: results are on-topic but boring and obvious. Cause: similarity weight too high, so you are returning near-duplicates. Fix: lower a, or add a category diversity rule that limits any one category to three slots.

Symptom: stale recommendations. Cause: window too long or half-life too generous. Fix: shrink the window and check that your ts values are unix seconds everywhere, not a mix of seconds and milliseconds.

Symptom: query errors on popular seeds. Cause: the candidate stream passed the documented 1,000,000 unique entity limit. Fix: cap earlier in the traversal.

Symptom: fewer results than expected from a scoped search. Cause: a traversal-scoped search rejects rather than silently clamping when it exceeds the 800 ceiling after intersection. Fix: reduce the candidate cap or the requested limit.

If you want to benchmark the retrieval layer itself, how to benchmark a graph database for AI retrieval lays out a method. Measure on your own data, not someone else's numbers.

Conclusion

The architecture is simple to state: the graph decides who is relevant, the vectors decide how relevant, and the timestamp decides whether it is still relevant. Most stacks need three systems and a sync job because the stores were never built to share a candidate set.

Take the two-store prototype you built in Step 2 and port it. Run HelixDB locally (helix init local, then helix start dev, which needs Docker or Podman), or open it embedded from the TypeScript SDK with Client.embedded. Load a few thousand items and interactions, write the Step 3 traversal and the Step 4 pre-filtered vector query, and delete the sync job once the results match. Star the repo on GitHub, and join the Discord to show us the recommender you ship.

Build with HelixDB

Give your coding agent the setup prompt, or sign up and deploy a database.

Sign up