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.
Top comments (0)