Databases

Trying a graph database for a client recommendation engine

DB

A retail client wanted a "customers who bought this also bought that" recommendation feature, and our first attempt modeled it in the existing MySQL database with a join table tracking co-purchases. It worked for simple one-hop queries, but anything resembling "products bought by people similar to this customer" turned into a recursive query nightmare that took longer to write than it took to run, and took longer to run than any page load budget could reasonably tolerate.

Why relational modeling struggled here

The fundamental mismatch is that relational databases are built around fixed schemas and predictable join patterns, while a recommendation feature's real question — "how are these two things connected, through however many intermediate steps it takes" — is exactly the kind of open-ended traversal relational joins handle poorly. Every additional hop in the relationship we wanted to explore meant another join, and query planners simply do not optimize multi-hop traversal queries the way they optimize the simpler lookups relational databases were designed around.

Bringing in Neo4j

We brought in a graph database, Neo4j, specifically for this feature, modeling customers, products, and purchases as nodes and relationships rather than rows and foreign keys. Queries that were previously several nested subqueries in MySQL became a handful of lines of Cypher, Neo4j's query language, and performance on multi-hop traversals — the whole point of the feature — was dramatically better, since a graph database is built exactly for that access pattern rather than adapting a general-purpose relational model to fit it.

What the actual data model looks like

  • Customer nodes, connected to product nodes via `PURCHASED` relationships carrying a timestamp and quantity as properties
  • Product nodes, connected to each other indirectly through shared purchase patterns rather than any direct relationship we define ourselves
  • A `SIMILAR_TO` relationship we compute periodically as a batch job, precomputing customer similarity scores rather than calculating them fresh on every single recommendation request

Keeping it scoped narrowly

We kept the graph database scoped narrowly to this one feature rather than migrating anything else off MySQL, syncing purchase data into it via a queued job whenever an order completes. This was a deliberate decision rather than a limitation we ran into — introducing a second database technology into a stack always adds operational surface area, and we did not want to justify that cost for anything beyond the specific problem a graph database actually solves better than the alternative. MySQL remains the source of truth for the order itself; Neo4j is a derived, purpose-built index for one specific access pattern.

Keeping the two databases in sync

The sync job itself needed more care than we initially expected. An order completing in MySQL triggers a queued job that writes the corresponding purchase relationship into Neo4j, but we had to handle the case where that job fails or is delayed without ever letting the recommendation feature serve on stale-enough data that it recommends something clearly outdated, and without ever letting a sync failure silently drop data with no record that it happened. We added a periodic reconciliation job that compares recent order counts between the two systems and alerts if they drift beyond a small tolerance, which has caught a couple of genuine sync failures before they accumulated into a meaningfully inaccurate recommendation feature.

Results and what surprised us

For the right shape of problem — and "relationships between things" is a very specific shape — a graph database earns its keep quickly. What surprised us most was not the query performance improvement itself, which we expected, but how much more naturally new recommendation ideas could be expressed once the data model matched the actual shape of the problem. A product manager asking "can we also recommend based on what people in the same city bought" turned into an afternoon of Cypher experimentation rather than a multi-day database redesign conversation, simply because the graph model already had customers and products as first-class connected entities rather than rows joined through an intermediate table built for one specific query pattern.

What we would tell a team considering the same move

A graph database is not a general-purpose replacement for a relational database, and we would push back hard on any suggestion to migrate more of this client's data into Neo4j purely because it worked well here. The lesson we took from this project is narrower and more useful: identify the specific access pattern that is genuinely graph-shaped, keep everything else where it already works well, and accept the operational cost of a second database only for the piece that actually needs it.

Evaluating the operational cost honestly

Before committing to Neo4j, we made a point of pricing out the full operational cost, not just the developer experience, since a technically superior tool that materially raises hosting cost is not automatically the right call for every client. Neo4j's managed hosting option came in meaningfully more expensive per month than the MySQL instance it was supplementing, and we walked the client through this tradeoff explicitly rather than presenting it as a purely technical decision — ultimately the projected lift in average order value from better recommendations covered the added hosting cost comfortably within the first couple of months, which made the decision an easy one once framed in those terms rather than as an abstract technical preference.

Query performance tuning we had to learn

Our first few Cypher queries, written by a team with strong SQL experience but no prior graph database background, were technically correct but noticeably slower than Neo4j is capable of, mostly from missing indexes on properties we filtered on frequently and from writing queries that traversed more of the graph than the actual question required. Neo4j's query profiler tool became essential here, showing exactly which part of a query was doing unexpectedly expensive work, in a way that let us tune query performance iteratively rather than guessing. A properly indexed and tuned version of our slowest initial query ended up roughly twenty times faster than our first working draft, which was a bigger gap than we expected between "technically correct" and "well written" for a query language none of us had deep experience with going in.

How editors and merchandisers interact with the recommendations

The recommendation engine itself runs without direct human input day to day, but the client's merchandising team wanted the ability to manually boost or suppress specific product pairings — promoting a strategic bundle, or preventing an awkward pairing the algorithm surfaced on its own, like recommending a clearance item alongside a full-price version of the same product. We added a small admin override table, checked before the graph-derived recommendation is shown, giving the merchandising team direct control without needing to touch the underlying graph queries themselves for what is ultimately a business decision rather than a technical one.

Explaining the tradeoff to less technical stakeholders

A recurring challenge through this project was explaining to non-technical stakeholders on the client side why "just add this to the existing database" was not actually the simpler option it sounded like. We found that a concrete before-and-after example — showing the actual multi-page nested SQL query next to the equivalent handful of lines of Cypher — communicated the real difference far more effectively than any abstract explanation of graph theory or query planning ever did in these conversations. We also built a small internal dashboard letting the client's own data team explore the graph visually for ad hoc questions outside the main recommendation feature, which turned out to be a popular side benefit nobody had specifically asked for at the start of the project.

A follow-up question the client asked that we had not anticipated

Several weeks after launch, the client asked whether the same graph model could answer a completely different question they had not originally scoped: identifying which products were frequently purchased together but rarely appeared in the same marketing campaign, as a way of finding untapped cross-promotion opportunities. Because the underlying graph already modeled customers, products, and purchases as connected entities rather than rows tied to one specific feature, answering this new question was a matter of writing a new Cypher query against existing data rather than a new data modeling project, which is exactly the kind of flexibility we had hoped a graph-shaped data model would eventually pay off in ways beyond the original recommendation feature alone.

← Back to the journal

Have a project in mind?
Let’s talk.

Tell us where you are and where you want to go. We'll map the fastest route between the two.

Currently accepting new clients