I built a movie recommender for people who can’t describe their own taste, a dead feature taught me a lot about dense vector spaces.


It is often difficult for people (me) to say what films they really like when asked. It’s not a malicious lie, but something that is born out of chronic indecisiveness. Most often we find ourselves saying things like “I like action thrillers”, or “I’m open to everything”. Finally, when a film that is well received is put-on, I find myself on the phone thirty minutes in.

Being an unreliable narrator of your own taste is part of human nature. But it is much easier to choose between two films when given concrete options, an answer comes almost instantly and with confidence. This gap of being bad at describing taste, but great at choosing from options is the entire premise of this project. It is an idea that I have had for a while but not enough time to devote to it, and now with Claude Code making rapid prototyping much more feasible I have been building CineGeist. It is a conversational movie recommender for people who can’t articulate what they want.

This article is a build log which covers one of the main principles that this tool depends on, the architecture to prevent a language model from hallucinating or false aligning, and finally a feature that was designed carefully, shipped, and later discovered to be mathematically incapable of ever running. Since this is something often happens to people, I believed it to be something worth writing down to tell people.

The one rule: never ask an abstract question

The entire system is designed around a simple statement: Don’t ask people an open ended question to describe their taste, instead ask them to react and choose between concrete options.

CineGeist never says “do you like slow films?” It says: “You’ve got Hereditary and Crank in front of you tonight. Which one?”. The answer that you give to that one question means more than a paragraph of uncertain descriptions of your favorite genres. Since you are not theorising about yourself and are just reacting, you are doing something that humans are much better at.

Under the hood, the tag genome is a vector space that embeds “taste”. MovieLens ships a dense matrix of about 13,000 films scored across roughly 1,100 descriptive tags (atmospheric, cerebral, bleak, twist ending, quirky), each with a relevance score between 0 and 1. Your profile is treated as a weighted point in that vector space calculated by the average of the films you reacted well to and pulled away from the ones you didn’t. The recommendation is then found using cosine similarity, by finding films whose vectors are similar to the one embedded by your “taste”. The whole operation is a single matrix multiplication over the entire catalog, something that takes a few milliseconds.

The rule that keeps the model honest

There is one hard constraint in this project, a film is never named in the linguistic model. Until you have seen an LLM boldly suggest a movie that doesn’t exist, miscredit a director, or create a sequel, this seems random. Because the user cannot discern the difference until they search for a movie that was never real, a hallucinated recommendation is worse than none at all.

So the math and the model have distinct roles. Deterministic Python handles all aspects of display, including retrieval, scoring, and question scheduling. The model only performs four things, all of which are language and never facts: it converts a chosen question into a natural sentence, extracts structured signals from your free-text answers, reranks a previously generated shortlist, and writes an explanation for a pick. Every title the user sees is from the local catalogue. If the model produces an ID that was not on the shortlist we provided, we discreetly drop it and log it. The model becomes eloquent. It never becomes the source of truth.

The great side effect is that because the math decides and the model only phrases, the entire system works without the model present. That is how the browser demo works. It re-implements the scoring in TypeScript against a 2,000-film shard, pre-phrases the questions offline, and drops the LLM entirely. It is a real recommender, not a mockup.

The wildcard: a good idea on paper

Now we come to the interesting feature that I want to discuss. Every competent recommender has filter-bubble issues. If you merely show them more of what they already like, you limit their options and prevent them from discovering anything new. The typical solution is an exploration slot. My suggestion was to present a film that is not your typical taste, but still has a connection to what you enjoy. This is known as the “wildcard” approach.

I defined it precisely, which works the best:

  • Far means a low cosine similarity to your profile. I set the ceiling at 0.35.
  • Not random means the film still loads highly (relevance at or above 0.5) on at least two of your profile’s own strongest tags.

Then, choose the film with the highest score from those that clear both bars. If nothing qualifies, show nothing, because an honest empty spot is preferable than a forced lousy option. Clean, reasonable, and when tested among thousands of simulated users, it almost never appeared.

Measuring a corpse

You can’t estimate when a feature is so silent, you take measurements. So, I created 3,000 artificial identities, each based on six popular films, and compared each one to the actual 16,376-film measured genome. The functionality came to life when I swept the cosine threshold.

 MAX_COS   % of users who got a wildcard
    0.35        0.0%
    0.40        0.0%
    0.45        1.1%
    0.50       26.1%
    0.55       96.2%
    0.60      100.0%

At the shipped setting of 0.35, the number is not “low.” It is zero. Not once in 3,000 users.

The reason is geometry, which is something that is only visible in retrospect. The genomic vectors are dense and not negative. Almost every film includes at least some of each category, and nothing is ever negative. Everything in that space is fairly similar. I compared the cosine of a typical user’s profile to all 16,376 films:

  1st percentile:  0.588
  median:          0.748
  99th percentile: 0.899
  films below cosine 0.50:  0.00%

The closest one percent of the entire catalog still sits at 0.59. The single most distant film from a typical profile averages around 0.49. My “far” threshold of 0.35 was set below the floor of how far any film in the catalog can actually be. I had built a door with the handle underground. The feature was not just rare, it was structurally impossible, but I shipped it that way, and all of the tests passed because “return nothing” was an explicitly allowable consequence.

The part that’s worse than a dead feature

The obvious solution is to “raise the threshold until it fires.” So I looked into what happens when you do. At 0.55, 96% of users receive a wildcard. Films recommended include Battleship, Resident Evil: The Final Chapter, Twilight: New Moon, The Amazing Spider-Man 2, and High School Musical. Not exciting hidden jewels. The catalogue contains the most generic mainstream blockbusters.

That is not a coincidence, because it explains why the entire feature is misconceived rather than simply mistuned. I needed a movie to share two of the strongest tags for my “not random” guardrail. After analysing user feedback, I discovered that the most commonly used tags were “original,” “mentor,” “storytelling,” “catastrophe,” “great ending,” and “good soundtrack”. Tags with a significant load on half of the catalogue. So “shares two of your strong tags” filters absolutely nothing, and when you lower the distance bar, the films that rush in are precisely the ones that load on everything, the major blockbusters.

So the two halves of my definition were constantly tugging in opposite directions. “Far in cosine” and “shares your strongest tags” are contradicting conditions in this space, as your strongest tags indicate the direction your profile points. A film that shares them is drawn toward you, not away. There is no value for that barrier that will give me what I want. When the feature is silent, nothing happens; forcing it to speak results in garbage. The number was never the issue, it was the idea.

What I did about it

The tempting move, which my own project guidelines expressly prohibit, is to subtly raise the threshold so that the demo appears adventurous and confident. This transaction results in a smart, but incorrect product. I would prefer to ship an honest empty space than a dishonest full one.

So, the wildcard is retired rather than retuned. If exploration returns, it will be rebuilt from the geometry up, with distance measured relative to your own ranked pool rather than an absolute cutoff that ignores how the space is actually shaped. It will also be “grounded” by distinct shared tags that are strong for you and comparatively rare across the catalogue, rather than broad tags that everyone shares. There is also a quality and popularity barrier, so the exploration slot cannot simply offer you the most mainstream object that occurred to clear the bar. Until it exists and I can quantify its success in finding actual jewels, the truth is that I only have three confident picks and no fourth.

The lesson I keep relearning

Every time I work in a high-dimensional vector space, I relearn the same thing. Distance intuitions are incorrect, and in a subtle way. “Far” and “near” do not act the same on a map. Density compresses everything toward the middle; a threshold that appears bold on paper can be completely beyond the range of the data, with no errors. You’ll just get silence, which is simple to ignore.

The goal would be to capture this in design. But this does not happen. You catch it by measuring the corpse: create thousands of users, sweep the parameter, examine the true distribution of the data, and read what the feature performs at each value rather than the one you delivered. The maths doesn’t care how eloquent your definition is. It will only tell you the truth if you ask in numbers.

CineGeist is open source, runs locally, keeps your taste profile on your own machine, and works offline without any API key. If you have ever failed to describe a film you wanted and then known it in one second when someone named it, it was built for you.

Try it and take the code:

  • Live browser demo (run’s completely on the browser, no information leaves): arijitgupta42.github.io/cinegeist
  • Source, and the threshold-sweep script from this article: github.com/arijitgupta42/cinegeist

If the design principle resonates, a star on the repo helps other people find it.


CineGeist uses the MovieLens tag genome (Vig, Sen & Riedl, 2012; Harper & Konstan, 2015) and the TMDB API. This product uses the TMDB API but is not endorsed or certified by TMDB.