← 목록

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

devto 2026-08-17 원문 보기 ↗


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

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

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

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

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)

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