In v1 I built a Codenames spymaster assistant out of word embeddings and cosine similarity. It worked okay, and at the end I named the weakness. Embeddings give you a vague semantic cloud rather than a concrete connection you can point at. I said a graph would fix that. So I built one, out of the whole Wikipedia link graph in Neo4j, and benchmarked it against a GloVe baseline and a pure LLM on real games. The graph turned out fast, auditable, and free to run. It also has a yield gap I could not close, and the boring LLM does the job. The figures below run live in your browser.
The problem with the embedding approach was that it could not explain itself. Cosine similarity would rank OCEAN and SHARK as close, but it could not tell you why, and it fell apart on proper nouns and on polysemy, words that carry more than one meaning, like BANK for a riverbank or the place that holds your money. What I wanted instead was a connection I could trace. SHARK and OCEAN both link to the Wikipedia article for Marine biology, so "marine" is a candidate clue that bridges them.
That is the entire idea. Pick the board words on your team, find the Wikipedia articles that several of them link to, and the best of those shared neighbors is your clue. The more team words a single article bridges, the better the clue. Toggle the team words below and watch the shared-neighbor clue fall out.
A graph database stores data as nodes and edges instead of rows and columns. For Wikipedia that mapping is free: every article is a node, every hyperlink is an edge. The full English graph is about 13.6 million nodes and hundreds of millions of edges, and it lives in Neo4j on my machine.
You query it by describing the shape you want and letting the database find every match. In Cypher, Neo4j's query language, (a)--(b) means "a connected to b by any edge," and you can chain it. So "find every article that both SPACE and ROCKET link to" is almost literally the query:
(space)--(candidate)--(rocket)
Everything else is filtering and scoring bolted onto that one pattern. Not every article makes a good clue, though. "List of mammals by country" links to a great many things and is useless as a clue, so I precompute a GoodClue label on the articles that pass a degree filter (200 to 10,000 links) and some title-pattern filters, once, offline. Then a clue's base score is -log10(degree) * 25 + 10. Articles with middling connectivity score highest, because they are specific enough to be useful but connected enough to bridge more than one word.
The first working generator averaged 4.9 seconds a board, and a board with a high-degree word like "gold" or "lead" could take 32 seconds while the query walked an enormous neighborhood. Three changes, applied together, took the average down to 40 milliseconds and made the worst case disappear. Each one moves work the board doesn't need out of the per-request path.
Flip them on one at a time below.
LIMIT 150 stops the opponent-overlap scoring from running on thousands of candidates. The lower cutoff keeps high-degree words like "gold" from triggering the giant traversal that caused the 32-second outliers.To find out whether any of this was good, I ran three generators against 50 real boards from the SALT-NLP dataset of human-labeled Codenames games: the Neo4j graph, the original GloVe embedding baseline, and a pure LLM that gets the whole board handed to it. I tracked yield (did it produce a clue at all), precision against the human clue's targets, single-word rate as a proxy for legality, and latency.
| Dimension | Graph (Neo4j) | Pure LLM (Gemini) | GloVe |
|---|---|---|---|
| Yield | 68% | ~100% | 86% |
| Avg latency | 40 ms | 2–5 s | 12 ms |
| Precision@K | 0.941 | — | 0.965* |
| Legality | strong | weak | medium |
| Polysemy | no | yes | partial |
| Runtime cost | ~$0 | ~$0.002–0.005 | $0 |
That 68% bothered me. My first assumption was that board words like "sub" or "boom" weren't matching Wikipedia titles, so I built a whole WikipediaWordMapper with full-text search, embedding rerank, and an LLM disambiguation call to resolve them. I wired it in, re-ran the eval, and yield came back unchanged at 68%.
The words map fine. "Sub" resolves to the "Sub" article without any help. The problem is what comes next. "Sub" has 40 Wikipedia links total, and none of its neighbors clear the GoodClue floor of 200. The query returns nothing because there is nothing specific enough to bridge from. So I tried the obvious knob, lowering the floor. Drag it and watch what happens to the three numbers that matter.
Given the gap, I added a pure-LLM baseline. Hand the whole board to Gemini with a prompt asking for the best clues, and parse the JSON it returns. It handles polysemy without being told to, because it knows BANK means money in a financial board and a river otherwise. It writes clues a person would use, and by construction it always returns something. The costs are real: 2 to 5 seconds a board, no way to verify why a clue connects, and the occasional confident hallucination. But structured reasoning over a small fixed set of words is what these models are now good at.
If you want a Codenames clue generator that works and you can spend a fraction of a cent per board, use the LLM. It has full yield, writes natural clues, handles proper nouns and slang, and needs nothing but an API key.
The graph is worth keeping when you need to know why a clue connects. You can trace the exact Wikipedia articles that link a clue to each team word, which makes the system tunable and auditable in a way the LLM is not, and it runs in 40 milliseconds with no API bill. The right setup uses both. Run the graph first, and fall back to the LLM on the boards where the graph comes up empty. That gets you close to full yield while keeping the fast, traceable path for the boards where it works.
I set out to prove the graph would win and it didn't, quite. It is the better engineering artifact and the worse product, and for most people the answer is to prompt the model and ship it. The code for all three, plus the eval harness, is on GitHub.