DEV Community

Cover image for I built a content recommender as a graph, and it found similarities I wasn't looking for
mbadr3227-sys
mbadr3227-sys

Posted on

I built a content recommender as a graph, and it found similarities I wasn't looking for

Most beginner recommendation projects are a dictionary lookup. You type
"comedy", it returns the comedy list. That works, and it is also the
reason those projects are forgettable: a dictionary can tell you what is
similar, but it can never tell you what is interestingly different.

I wanted the second thing. So I modelled the catalogue as a weighted
graph instead, and ended up finding a kind of similarity I had not
designed for.

The project is called CreatorRoute. It recommends short-form content
formats: you name a video you liked, and it gives you close matches plus
a few deliberate outliers.

The data

Forty rows of short-form videos, hand-written, five attributes each:

id,title,niche,hook_type,length,editing_style,cta_type
4,Stop posting at 9am,marketing,contrarian,short,jump-cut,comment
23,Stop using this transition,editing,contrarian,short,jump-cut,comment
Enter fullscreen mode Exit fullscreen mode

Remember those two rows. They matter later.

The one rule I followed while writing the dataset: every attribute value
had to repeat across several rows. Unique values produce isolated nodes,
and isolated nodes make a graph that cannot be traversed. If every video
had its own one-off hook_type, there would be no edges to walk.

Building the graph

Every video is a node. Two nodes get an edge when they share at least
one attribute. The edge weight is the inverse of the number of shared
attributes:

ATTRIBUTES = ["niche", "hook_type", "length", "editing_style", "cta_type"]


def shared_attributes(a, b):
    return sum(1 for attr in ATTRIBUTES if a[attr] == b[attr])


def build_graph(items):
    graph = {item_id: [] for item_id in items}
    ids = list(items)

    for i in range(len(ids)):
        for j in range(i + 1, len(ids)):
            a, b = items[ids[i]], items[ids[j]]
            shared = shared_attributes(a, b)

            if shared > 0:
                weight = 1 / shared
                graph[ids[i]].append((ids[j], weight))
                graph[ids[j]].append((ids[i], weight))

    return graph
Enter fullscreen mode Exit fullscreen mode

Inverting the count is the whole trick. Four shared attributes gives a
weight of 0.25; one shared attribute gives 1.0. More in common means a
shorter edge, which means shortest-path algorithms rank by similarity
without any extra work.

The range(i + 1, ...) avoids comparing each pair twice.

Where BFS failed

My first plan was breadth-first search alone. Depth 1 for close matches,
depth 2 for discovery. Clean, simple, no weights needed.

It did not work. At 15 items, a BFS from any node reached 10 of the 14
others at depth 1
. One shared attribute is enough for an edge, and with
five attributes across a small catalogue, nearly everything connects to
nearly everything.

BFS answers is this reachable, and in how many hops. In a dense graph,
the answer is almost always "yes, one hop", which is not a ranking. A
video sharing four attributes and a video sharing one were both simply
"neighbours".

Where Dijkstra fixed it

Dijkstra's algorithm walks the same graph but accumulates weight instead
of counting hops:

import heapq


def dijkstra(graph, start):
    distances = {start: 0}
    heap = [(0, start)]
    settled = set()

    while heap:
        dist, current = heapq.heappop(heap)

        if current in settled:
            continue
        settled.add(current)

        for neighbor, weight in graph[current]:
            new_dist = dist + weight
            if new_dist < distances.get(neighbor, float("inf")):
                distances[neighbor] = new_dist
                heapq.heappush(heap, (new_dist, neighbor))

    del distances[start]
    return distances
Enter fullscreen mode Exit fullscreen mode

Same neighbours, real ordering. Something four-fifths identical scores
0.25 and something barely related scores 1.0.

BFS was not wasted, though. It moved jobs: it now supplies the discovery
list, pulling nodes at depth 2 that share nothing directly with your pick
but sit two hops away. Two algorithms, two questions, one graph.

The part I did not plan

Here is the output for "Stop posting at 9am", a marketing video:

Because you liked: Stop posting at 9am
(marketing / contrarian / jump-cut)

  Closest matches:
    - 3 mistakes killing your ad spend  [0.25]
    - The one word killing your CTA     [0.25]
    - Stop using this transition        [0.25]

  Worth exploring:
    - 6 free tools I use daily
    - 5 onboarding flows that work
    - My first 1000 orders
Enter fullscreen mode Exit fullscreen mode

The third close match is about video editing. The second is about
copywriting. Neither is marketing.

They rank at the top because they share contrarian + short +
jump-cut + comment. The graph had quietly learned to match on
format rather than topic — same shape of video, different
subject entirely.

I did not build that. I built "count shared attributes". Treating topic
as one attribute among five, rather than as the primary key, was enough
for structural similarity to emerge on its own. A dictionary keyed on
category could not have surfaced it, because the key would have thrown
the other four attributes away before the comparison started.

Search has the same trade-off

The CLI needs to find your video before it can recommend anything, and
that turned out to be its own small lesson in complexity.

prefix_search uses binary search over a sorted index — O(log n), but
only matches titles that start with your query. keyword_search scans
every title at O(n) and matches anywhere. Searching "ad" finds nothing
with the fast one and four titles with the slow one, including
"Rewriting a bad ad live".

The CLI tries fast first and falls back:

def find_matches(items, index, query):
    hits = prefix_search(index, query)
    return hits if hits else keyword_search(items, query)
Enter fullscreen mode Exit fullscreen mode

At 40 items the difference is unmeasurable. Writing both anyway made the
trade-off concrete in a way that reading the Big O table never did.

What I would change

The dataset is hand-written, which caps how much the graph can surprise
me — I chose the attributes, so I partly chose the connections. Real
scraped data would be a better test.

The build_graph function is O(n²): every pair compared. Fine at 40
items, painful at 40,000. The fix is bucketing by attribute value and
only comparing within buckets, which I have not needed yet.

And attributes are currently equal. Sharing an editing_style counts as
much as sharing a niche, which is probably wrong. Weighting them
differently is the obvious next experiment.

Takeaway

If you are working through a recommendation project, resist the
dictionary. The graph is not much more code — under 200 lines total, no
dependencies outside the standard library — and it gives you somewhere
to put a second algorithm, a real reason to care about edge weights, and
occasionally a result you did not design.

Code: github.com/mbadr3227-sys/creatorroute

Top comments (0)