At DailyWatch, our "related videos" panel started with a humble SQLite query. It joined three tables, counted overlapping tags, and returned a ranked list. For a small catalog, that was enough. A cooking video tagged "italian" and "pasta" would surface other videos with the same tags, and users clicked. The output looked relevant because the metadata was clean and the library was shallow.
Then the catalog grew, and audience expectations shifted. People did not simply want more videos with identical tags. They wanted the clip that forty percent of viewers watched immediately after the current one. They wanted the niche channel that kept surfacing in the same late-night viewing sessions, even though its description mentioned nothing about the starting video and its tags were bare. These are behavioral patterns, not metadata matches. SQLite could not express them without devolving into an unmaintainable nest of self-joins and recursive common table expressions. Every new signal we tried to model, whether co-viewership or session adjacency, added latency and mental overhead. We had a graph problem squeezed into relational clothing.
I moved the recommendation layer to Apache AGE.
Apache AGE is a PostgreSQL extension that adds openCypher graph queries to the database you already run. It is not a separate server. It is not a sidecar. It runs inside Postgres, which meant we could build a co-view network without standing up a dedicated Neo4j cluster or learning an entirely new operational playbook. For a small team without a database reliability engineer, that distinction mattered enormously.
Why an Extension Beats a New Database
Adding a graph database to your stack is easy on a whiteboard and expensive in production. You need new monitoring dashboards, new backup procedures, new connection pools, and new failover logic. AGE sidesteps all of that because it lives inside your existing Postgres instance.
There are four practical reasons this worked for us.
- No new infrastructure. Because AGE is an extension, your current
pg_dumpschedule, your existing replicas, and your standard Postgres health checks all continue to work. You do not need to convince operations to babysit another data store. - Mixed workloads in one query. AGE lets you write Cypher inside SQL. You can run a graph traversal to find candidate videos, then join that result set against your relational
userstable to enforce regional content restrictions, or against a relationalsponsorshipstable to deprioritize certain channels. One round-trip. Two query languages cooperating. - Portability. Cypher is an open, well-documented graph query language. If DailyWatch outgrows AGE and needs to migrate to Neo4j or Memgraph later, the query logic transfers with minimal rewriting. You are not locked into a proprietary dialect.
- Compatibility. Because the data ultimately lives in PostgreSQL, your existing PHP or Python tooling does not change. You connect with the same driver, handle the same connection strings, and fetch rows the same way. The graph logic sits in the query layer, not the application layer.
Modeling Co-Viewership
The implementation is straightforward. We defined nodes for the entities we cared about: Video and Channel. We then defined edges for the relationships between them. A PUBLISHED edge links a Channel to a Video. A CO_VIEWED edge links one Video to another, carrying a weight property that represents how frequently the two videos appeared in the same viewing session.
This model captures something that tag-based SQL cannot: the implicit structure
