Skip to content

Semantic Movie Search with Embeddings

Build a small Netflix style search. Embedding model vs chat model, why sending the whole catalogue to an LLM fails, precomputed vectors, top K by cosine, and similar movies, in Spring AI, Node.js and Python.

Builds on Vector Embeddings from First Principles

Stage 6core27 min read

It is late at night and you want to watch a film. You cannot remember the name. You only remember the feeling: someone stuck in space, trying to get home. You type that into the search box and get nothing, because none of your words are in any title. This note builds a search box that understands what you mean, plus a "more like this" row under every film. No chat model picks the films. Embeddings and the cosine maths from note 7 do all of it.

Note 7 explained how text can become a vector and how to measure the gap between two vectors. This note puts that to work on a real problem. We look at how an embedding model differs from a chat model, why pasting a whole catalogue into a prompt is a bad plan, how to embed the catalogue once and reuse it, how to rank by cosine and keep the top few, and how "similar movies" falls out almost for free. The code is a small service in Spring Boot, Node.js and Python under code/08-movie-recommender/, with 30 real films and descriptions written fresh for this repo.

Here are the words used in this note. Each one is explained again the first time it shows up, but this table is the short version:

WordWhat it means here
Embedding modelA model that turns text into a fixed length list of numbers. It never writes text
Chat modelA model that writes text one token at a time. Also called a generative LLM
CatalogueEvery item an app has. Here, every film on our platform
Keyword searchSearch that only finds items that contain the same words you typed
Semantic searchSearch by meaning. The words can be different as long as the idea is the same
Query vectorThe embedding of what the user typed into the search box
PrecomputeDo slow or costly work once, save the result, and reuse it
Batch callOne API request that carries many inputs at the same time
Cosine similarityA score from -1 to 1 for how much two vectors point the same way (note 7)
Top KThe K best scoring items. K is just a number you choose, like 3
Content based recommendationSuggest items whose content is like something you already liked
Collaborative filteringSuggest items that people with taste like yours liked

1. Quick Recap: Meaning as Numbers

Read these three sentences:

A: A woman is stranded alone in orbit after an accident.
B: Someone has to stay alive far away from Earth.
C: Two sisters open a bakery in a small town.

You can tell at once that A and B are about the same kind of story. C is about something else. But look at the words. A and B share almost nothing. "Stranded", "orbit" and "accident" on one side. "Stay alive", "far away" and "Earth" on the other. A computer that only compares words would say A and B are strangers.

Note 7 solved this by turning text into vectors. A vector is just a list of numbers. An embedding model reads a sentence and gives back one. Sentences with similar meaning get lists that point the same way.

Loading diagram…

The model never compares words. It gives each sentence a spot in a big space of numbers. A and B get spots close together.

You do this yourself every day. One friend says "the shop next to the big temple". Another says "the store opposite the old mandir". Different words, same shop. A shop assistant hears "phone charger" and "cable to power my mobile" and hands over the same thing. Embeddings give a computer a rough version of that skill.

2. Do the Numbers Have Names?

When we teach vectors, we put a label on each number. A movie might be [space 0.9, crime 0.1, love 0.2]. That is easy to read and we use it again in section 19.

Real embeddings do not work like that. Position 17 is not "sadness" and position 284 is not "space". Note 7, section 31, called these latent dimensions. Latent means hidden: the model learned them on its own and nobody named them.

Loading diagram…

The meaning is spread across all the numbers together, the way a face is spread across all the pixels in a photo. No single pixel is "the nose".

So do not try to read one number. What matters is the whole position, and the rule that similar meanings end up close.

3. What an Embedding Model Does

An embedding model is a model whose only job is to produce embeddings. Text goes in. A vector comes out.

Loading diagram…

The output length never changes. OpenAI's text-embedding-3-small, the model our code uses, always gives 1,536 numbers. Send it one word, a sentence or a full paragraph, and you still get 1,536 numbers back.

A passport photo booth works the same way. Tall person, short person, child, grandparent: everyone comes out as the same size photo. That fixed size is what makes comparing them easy.

An embedding model turns any text into a list of numbers of one fixed length.

4. Embedding Model vs Chat Model

Both kinds of model start the same way. Text is split into tokens (small pieces of words), the tokens become id numbers, and a Transformer network reads them. After that, their goals differ.

A chat model is trying to guess the next token. Give it this:

The batsman hit the ball over the ...

and inside, it gives each possible next word a chance, something like:

boundary  0.41
rope      0.22
fence     0.09
fielder   0.05

It picks one, adds it to the text, and guesses again. Token after token until the answer is done. Note 2 and note 6 covered this loop.

An embedding model is not guessing anything. Give it the full sentence and it gives back one vector. That is the whole answer.

Loading diagram…

The left side loops. The right side runs once and stops.

Chat modelEmbedding model
InputTextText
OutputText, written one token at a timeOne fixed length vector
Can it explain or chat?YesNo
Output sizeChanges with the answerAlways the same
How many stepsOne per output tokenOne
Price per tokenHigherMuch lower
Good forWriting, summarising, answeringSearch, matching, grouping, ranking

5. Can an Embedding Model Answer a Question?

No. This is a common mix-up, so it gets its own section.

Ask a chat model "What is the capital of India?" and it replies "New Delhi". Send the same question to an embedding model and you get numbers.

Loading diagram…

The second answer is not wrong. It is a different kind of answer. It says "this question sits here in meaning space". It does not say what the answer is.

One more example. Ask "Which platform does the Rajdhani Express leave from?". A chat model will try to answer, and may even make something up (note 4 called that a hallucination). An embedding model gives a vector. You could use that vector to find the right page in a railway help guide, but the vector alone tells the passenger nothing.

An embedding model represents text. A chat model writes text.

6. Two Kinds of "Recommend Me a Film"

Chat models can recommend movies. Type "I loved Interstellar, suggest three more" and you might get Arrival, The Martian and Gravity. So why build anything?

Because there are two very different questions hiding behind "recommend me a film".

Scenario 1 is general knowledge. "What is like Interstellar?" A chat model has read a lot about famous films during training. For this question, asking it is fine.

Scenario 2 is your own data. Say we run a streaming app called Reelbox. It has 8,000 films. Some are new. Some are regional films the model never read about. Some are our own productions. Some came out after the model's training stopped. The question is now "Which three films from our shelf are closest to what this user wants?".

Loading diagram…

The left side is about what the model knows. The right side is about what we have. This note is about the right side.

Think of a librarian who has read thousands of books. Ask "What is a good detective novel?" and they can answer from memory. Ask "Which detective novels are on the shelf in this branch right now?" and they have to look at the shelf list. A food app is the same. "What is a good biryani?" is general knowledge. "Which restaurants on our app deliver biryani to your street tonight?" needs the app's own data.

7. The Naive Plan: Paste the Whole Catalogue

The first idea most people have is simple. Put every film into the prompt and let the chat model choose.

Here are our 8,000 films:
1. Interstellar (2014): With crops failing all over Earth, a former pilot ...
2. The Martian (2015): Left behind on Mars after a storm, a botanist ...
3. ...
8000. ...

The user wants: a lost astronaut trying to get home.
Pick the three best films from the list.

Drawn out, the plan is one big prompt per search:

Loading diagram…

With ten films this works fine. With a real catalogue it runs into four problems. The next four sections take them one at a time.

8. Problem 1: The Catalogue Does Not Fit

Each film needs a title, a year and a one line description. Call that about 50 tokens. Now do the sums:

CatalogueTokens in the prompt
10 films500
1,000 films50,000
8,000 films400,000
100,000 products in a shop5,000,000

Note 4 explained the context window: the most a model can take in for one request. 400,000 tokens is more than many models accept. A shop with 100,000 products is far past any of them.

Loading diagram…

Whatever falls outside the window simply does not exist for the model.

Even when it does fit, note 4, section 18, warned that a giant context makes the model slower and more likely to focus on the wrong part. You would be hiding the right film in a haystack and hoping the model finds it.

9. Problem 2: The Same Work, Again and Again

Say 10,000 people search Reelbox in one day. With the naive plan, every single search sends the whole catalogue again.

Loading diagram…

The films did not change between user 1 and user 3. Only the query did. Yet the model reads all 8,000 descriptions from scratch each time.

In numbers: 10,000 searches x 400,000 tokens = 4,000,000,000 input tokens a day. Four billion tokens, almost all of them the same catalogue read again.

Picture a shop where the cashier reads the full price list out loud to every customer before ringing up their bread. The list is the same for everyone. Reading it once and sticking price labels on the shelves would make far more sense.

10. Problem 3: We Only Need a Ranking

Look at what we actually want back. Not a paragraph like "The Martian would be a lovely choice because...". We want this:

The Martian   0.88
Gravity       0.84
Apollo 13     0.81
Toy Story     0.32

A score per film, then a sort. That is a ranking problem, not a writing problem.

Loading diagram…

Once films and queries are vectors, the score is plain maths. Nothing in this flow needs a model that can write.

A cricket points table is the same idea. To find the top four teams you do not need a commentator. You need points per team and a sort.

11. Problem 4: Cost and Waiting Time

A chat model writes its answer one token at a time (note 6 showed that loop). Even the three film names take many steps. And before it writes anything, it has to read the whole huge prompt.

An embedding model does one pass and returns. Better still, the films only need embedding once (section 13). After that, a search costs one tiny embedding call for the query, and the comparison runs as ordinary maths on our own server.

Loading diagram…

The left box repeats a huge job per search. The right box sends one short line and does the rest locally.

Per searchNaive planEmbedding plan
Input tokensAbout 400,000About 10
Output tokensAround 50 to 100None, just a vector
Model callsOne big chat callOne small embedding call
Work on our serverVery little8,000 cosine scores, a few milliseconds
Answer shapeText we must parseScores we can sort

Prices change often, so this note does not quote them. The shape of the difference does not change: hundreds of thousands of tokens against ten.

12. What Changes and What Stays the Same

Stop and ask a simple question. Which part of this system changes often?

The film descriptions? Hardly ever. Interstellar's story is the same today as it was last year. The user's search? Every few seconds, from a different person.

Loading diagram…

Stable data gets processed once and saved. Changing data gets processed when it arrives. The two meet only at the comparison step.

The same split shows up everywhere. A railway prints its timetable once a season, but answers "which train should I take now?" many times a minute. A restaurant prints its menu once, but takes a new order at every table.

13. Precompute Once, Reuse Forever

This trick has a name: precomputation. If something is costly and rarely changes, work it out once, store the answer, and reuse it.

Loading diagram…

The left box runs once. The right box runs thousands of times and never embeds a film again.

A batch call means sending many inputs in one request. Our code sends all 30 descriptions together and gets 30 vectors back, in the same order. That is one network round trip instead of 30.

When do the stored vectors go stale? Only in two cases:

  • A film's description is edited. Re-embed that one film.
  • You switch to a different embedding model. Re-embed everything, because vectors from two models live in different spaces and cannot be compared (note 7, section 34).

Everyday versions of the same idea: your phone sorts your contact list once instead of searching every name from scratch when you type a letter. A teacher writes the answer key once, not again for every exam paper.

If something is costly and rarely changes, work it out once and keep the answer.

14. Think Like an Engineer: Which Operation Do I Need?

It is tempting to start with "Which AI model is the smartest?". A better first question is "What operation does my problem actually need?".

Loading diagram…

Not every problem needs a model at all. "Films from 2016" is a database filter, not an AI task.

TaskOperationTool
Summarise this support ticketWrite new textChat model (note 3)
Write a polite reply emailWrite new textChat model
Find films like this descriptionFind nearest itemsEmbeddings + cosine
Group similar complaintsFind nearest itemsEmbeddings + cosine
List films released after 2010Filter by a fieldDatabase query
Add up an order totalArithmeticPlain code (note 5)

15. Embeddings and a Chat Model, Together

The two are not rivals. They can work as a team.

Say a user types: "an emotional space film where the hero is cut off from everyone". First the embedding search picks the closest films from our shelf. Then, if we want, we hand only those few films to a chat model and ask it to write a friendly reply.

Loading diagram…

The chat model now sees three short descriptions, not 8,000. It cannot suggest a film we do not have, because it only sees films we found.

Each tool does the job it is good at:

Embedding model  ->  SEARCH   (find the right items)
Chat model       ->  WRITE    (explain them nicely)

This pattern has a name, Retrieval-Augmented Generation, or RAG. Later notes build it properly. This note stops at the search half, because the search is the hard part.

Our Reelbox demo has 30 real films. Each one is stored like this:

{
  "title": "The Martian",
  "year": 2015,
  "genre": "Science fiction",
  "description": "Left behind on Mars after a storm, a botanist grows potatoes, rations every meal and works out the science he needs to stay alive until a rescue can reach him."
}

The films fall into seven groups, so you can see similar films land together in the results:

Loading diagram…

A few films sit between groups on purpose. WALL-E is an animated film that goes to space. Alien is a horror film set on a spaceship. Inception is a heist inside dreams. These are the interesting ones to search for, because they show the vectors are about meaning and not about the genre label.

Only the description is embedded. Users describe a story ("a lost astronaut trying to get home"), so we embed the story. The title, year and genre ride along so we can show them in the results.

The service has two features. Section 17 is search by meaning. Section 21 is "more like this".

17. Feature 1: Search by Meaning

A user types:

a lost astronaut trying to get home

First, try a whole-word keyword search. It looks for films whose description contains the words "lost", "astronaut" or "home". Here is what it finds:

  • Toy Story: "...the two rivals end up lost far from home together." Two matches, "lost" and "home". It comes out on top.
  • Apollo 13: "...three astronauts ... bring them home alive." One match, "home". To a whole-word match, "astronauts" is not the same word as "astronaut".
  • The Martian: "Left behind on Mars ... stay alive until a rescue can reach him." Zero matches. Not found at all.

So keyword search puts a cartoon about toys at the top and misses the most obvious film entirely. The Martian is exactly a lost astronaut trying to get home. It just uses other words.

Loading diagram…

Semantic search is search by meaning. The query and every description are vectors, and the closest vectors win. Shared words do not matter.

You will meet the same gap in other apps. On a food app, someone types "cottage cheese starter" and the menu says "paneer tikka". In a help centre, someone writes "money not back yet" and the article is called "Refund timelines". Keyword search misses both. Meaning search finds both.

18. The Search Request, Step by Step

Here is one search going through the service. The film vectors already exist (section 13).

Loading diagram…

Only one arrow goes to the embedding API, and it carries a few words. The sorting and scoring happen inside our own service.

19. Ranking With Cosine: A Worked Example

Real vectors have 1,536 numbers, which is too many to read. So here is the same idea with three named numbers: [space, crime, love].

ItemVector
Query: lost astronaut[0.9, 0.2, 0.1]
The Martian[0.9, 0.1, 0.2]
Interstellar[0.8, 0.1, 0.4]
Gravity[0.8, 0.1, 0.3]
Ocean's Eleven[0.1, 0.9, 0.1]
Notting Hill[0.1, 0.1, 0.9]

Cosine similarity, from note 7, is the dot product divided by both lengths. The dot product multiplies the numbers in matching positions and adds them up. A vector's length is the square root of its squares added up. Work it out for The Martian:

dot     = 0.9 x 0.9 + 0.2 x 0.1 + 0.1 x 0.2 = 0.81 + 0.02 + 0.02 = 0.85
|query| = sqrt(0.81 + 0.04 + 0.01) = sqrt(0.86) = 0.927
|film|  = sqrt(0.81 + 0.01 + 0.04) = sqrt(0.86) = 0.927
cosine  = 0.85 / (0.927 x 0.927) = 0.85 / 0.86 = 0.988

Do the same for the rest and sort:

RankFilmCosine
1The Martian0.988
2Gravity0.965
3Interstellar0.935
4Ocean's Eleven0.331
5Notting Hill0.237

Every score in that table came from the same few steps:

Loading diagram…

The space films score near 1. The heist and the love story score far lower. That gap is what makes the search useful.

20. Keeping the Top K

We do not show all 30 films. We show the best K. K is just a number the caller picks. Our service uses 3 when you do not say, and allows 1 to 10.

Loading diagram…

Why a limit at all? A screen row shows a handful of posters. A bigger K means a bigger response for no gain. And a fixed upper limit stops anyone asking for a million results.

With 30 films, sorting all of them and cutting the list is fine. With millions it is not, and that is the whole story of note 9.

Here is the search in Node.js. The full file is linked in section 31.

movieIndex.js
async search(query, k) {
  const entries = await index();               // built once, then cached
  const [queryVector] = await embed([query]);  // the only model call
  return rank(queryVector, entries, k);
},

export function rank(from, candidates, k) {
  return candidates
    .map(({ movie, vector }) => ({
      title: movie.title,
      year: movie.year,
      genre: movie.genre,
      score: cosineSimilarity(from, vector),
    }))
    .sort((a, b) => b.score - a.score) // highest score first
    .slice(0, k);                      // keep only the top K
}

embed always returns a list, so const [queryVector] picks out the one vector for our one query.

21. Feature 2: More Like This

Now the user has finished watching Gravity and the app shows "More like this". There is no search text this time. We already know the film.

And we already have its vector. We made it in the precompute step. So we skip the embedding call completely and compare Gravity's stored vector with the other 29.

Loading diagram…

Compare this with section 18. The embedding API is not in the picture at all. This route costs no tokens.

Using the toy vectors from section 19, "more like Gravity" gives:

RankFilmCosine with Gravity
1Interstellar0.995
2The Martian0.990
3Notting Hill0.459
4Ocean's Eleven0.255

Gravity itself is left out. Its cosine with itself is always exactly 1, and "If you liked Gravity, watch Gravity" is not a useful suggestion.

Here is the idea in Python:

movie_index.py
async def similar(self, title: str, k: int) -> list[MovieMatch] | None:
    entries = await self._index()  # stored vectors, no new embedding call
    wanted = title.strip().lower()
    source = next((entry for entry in entries if entry[0]["title"].lower() == wanted), None)
    if source is None:
        return None  # the HTTP layer turns this into a 404

    # Leave the film itself out: its score with itself is always 1.
    others = [entry for entry in entries if entry is not source]
    return rank(source[1], others, k)

next(..., None) takes the first film whose title matches, or None if none does. source[1] is that film's stored vector.

22. Search and Recommendation Are One Machine

Put the two features side by side:

Loading diagram…

Only the starting vector is different. Search makes a new one from text. Recommendation picks one from the store. The compare and rank steps are the same code.

That is a nice result for an engineer. Build the comparison once and you get two features. Our code has one rank function, and both search and similar call it.

Search and recommendation are the same comparison with a different starting vector.

23. Content Based vs Collaborative Filtering

What we built is a content based recommender. It looks at what a film is about and suggests films with similar content.

There is another big family, collaborative filtering. It ignores content and looks at people. "Users who watched X also watched Y."

Loading diagram…

The first box only needs the catalogue. The second box needs lots of viewing history.

A grocery app shows both. "Other brown breads" is content based: it looks at what the product is. "People who bought bread also bought eggs" is collaborative: bread and eggs are not alike at all, but shoppers buy them together.

Content basedCollaborative filtering
Looks atWhat the item is aboutWhat people did
NeedsItem descriptionsLots of user history
Brand new film, nobody has watched itWorks on day oneHas nothing to go on
Can surprise you with something differentRarelyOften
Our demoYesNo

That "brand new film" row is called the cold start problem: a new item has no history yet. A content based system does not have it, so it is a good first step.

24. What We Are Not Building

To keep the idea clear, the demo leaves a lot out on purpose:

Loading diagram…

Everything on the right is real and useful. Mixing it in now would hide the one thing this note is about: ranking by meaning.

25. Real Systems Mix Many Signals

A production recommender rarely uses one signal. It mixes several and weighs them.

Loading diagram…

Each arrow is a separate system. Our vector search is one of them.

A food delivery app does the same when it orders restaurants for you. It looks at distance, rating, how fast they deliver, what you ordered before, current offers and whether the place is open. "Is this dish similar to what you typed?" is only one line in that list.

26. The Core Mental Model

Before thinking about Spring, Express, FastAPI or databases, remember four steps.

Loading diagram…
  • Represent: turn meaning into numbers with an embedding model.
  • Store: keep those vectors. In our demo, a plain list in memory.
  • Compare: score a starting vector against the stored ones. The starting vector comes from a query or from a film.
  • Rank: sort the scores and keep the best few.

Every embedding powered search you will build has these four steps. The tools change. The steps do not.

27. The Whole Architecture

The service works in two phases. Phase 1 prepares the catalogue. Phase 2 answers requests.

Loading diagram…

Both routes end at the same compare and rank step. Only search talks to the embedding API during a request.

The three versions differ on one point: when phase 1 runs.

Loading diagram…

Spring creates its services once, at start-up, so that is the natural moment. If the key is wrong, the app fails to start and you see it at once. Node and Python wait for the first request, so importing the app or running the tests never needs a key. The first answer is a bit slower; every answer after that is fast.

28. Caching the Vectors

The index is a cache: a saved copy of work that would be slow to redo. Our cache has three states.

Loading diagram…

The failure arrow is the detail people forget. If the batch call fails, the code drops the failed attempt so the next request can try again. Caching the failure would leave the service broken until someone restarts it.

A second detail: if two requests arrive at the same moment while the index is empty, they must not both pay for the batch. Node caches the promise (a call still in progress), so the second request waits on the same call. Python uses an asyncio.Lock for the same reason.

Our cache lives in memory, so a restart means embedding the 30 films again. For 30 films that takes a moment and costs almost nothing. For a big catalogue you would save the vectors somewhere that survives a restart:

Loading diagram…

Each step to the right keeps the vectors safer and searches more of them quickly, at the cost of one more thing to run.

29. The Same Pattern Beyond Movies

Nothing in the code knows it is about films. Swap the catalogue and the same four steps work for many other jobs.

Loading diagram…

Whenever a problem sounds like "find the items in my data whose meaning is closest to this input", this is the shape of the answer.

30. Building It: the Movie Search Service

The Reelbox service has two endpoints:

EndpointWhat it does
GET /api/movies/search?q=...&k=3Films whose description is closest in meaning to q
GET /api/movies/similar?title=...&k=3Films closest to the named film, leaving that film out

Both return a JSON list, best first:

[
  { "title": "The Martian", "year": 2015, "genre": "Science fiction", "score": 0.61 },
  { "title": "Gravity", "year": 2013, "genre": "Science fiction", "score": 0.57 }
]

Those scores are only an example. Real embedding scores for short texts are often lower than the toy numbers in section 19, and the exact values depend on the model. The order is what matters.

The status codes:

StatusWhen
200Results, best first
400q or title is empty, or k is not a whole number from 1 to 10
404No film with that title
502The embedding API failed

The checks run in this order:

Loading diagram…

Every check that can fail without spending money runs first. Only then does the service call the API.

Decisions worth copying:

DecisionWhy
The whole catalogue is embedded in one batch callOne round trip instead of 30, and the vectors come back in the same order
Only the description is embeddedUsers describe stories, so we compare stories. Titles and genres are shown, not searched
The index is built once and keptFilms rarely change. Queries change all the time (section 12)
similar reuses the stored vectorNo embedding call, no tokens, nothing that can fail upstream
The film itself is left out of similarIts score with itself is always 1
The response has no vectors1,536 numbers per film would be useless bytes for the caller
A batch with the wrong number of vectors is an errorWe could not tell which vector belongs to which film
A failed batch is not cachedThe next request can try again instead of failing forever
Bad input is a 400 before any API callAn empty query would still cost a call and match nothing useful
The OpenAI client is passed in, not created insideTests use a fake embedder and need no key

31. The Code, Side by Side

The full projects are on the code page:

Browse the code for this note, or open src/FDE/code/08-movie-recommender/ on GitHub. The README there has the run and test commands for each version.

31.1 The precompute step

Embed every description in one call, then pair each vector with its film. This is the Spring version:

MovieService.java
private static List<IndexedMovie> buildIndex(EmbeddingModel embeddingModel, List<Movie> movies) {
    List<String> descriptions = movies.stream().map(Movie::description).toList();
    List<float[]> vectors = embeddingModel.embed(descriptions); // ONE call for all films

    // If the counts differ we cannot know which vector belongs to which film.
    if (vectors.size() != movies.size()) {
        throw new IllegalStateException("Asked for " + movies.size() + " embeddings, got " + vectors.size());
    }
    List<IndexedMovie> built = new ArrayList<>(movies.size());
    for (int i = 0; i < movies.size(); i++) {
        built.add(new IndexedMovie(movies.get(i), vectors.get(i)));
    }
    return List.copyOf(built);
}

The loop pairs vector number i with film number i. List.copyOf returns a read-only list, so nothing can change the index later.

Node and Python do the same with client.embeddings.create({ model, input: texts }). Each item in the reply carries an index, and the code sorts by it before pairing, just to be safe.

31.2 Spring AI vs Node.js

The same ideas, mapped line by line:

ConcernSpring AINode.js (openai SDK)
The model objectEmbeddingModel, built by Spring from application.ymlnew OpenAI() in server.js
Embed many textsembeddingModel.embed(List<String>) gives List<float[]>client.embeddings.create({ model, input: [...] }) gives data[].embedding
Embed one queryembeddingModel.embed(String) gives float[]the same call with a one item list
Model namespring.ai.openai.embedding.options.modelOPENAI_EMBEDDING_MODEL in .env
When the index is builtIn the MovieService constructor, at start-upOn the first request, then cached
CosineVectorMath.cosineSimilarity(float[], float[])cosineSimilarity(a, b) in movieIndex.js
Unknown titleOptional.empty()null
HTTP layer@RestController, @GetMapping, @RequestParamExpress app.get, request.query
Fake used in testsA fake EmbeddingModel that maps keywords to 3 numbersA fake client.embeddings.create with the same rule

Python follows the Node.js column almost exactly: await client.embeddings.create(model=..., input=[...]), None for an unknown title, and a fake client in the tests.

The fake embedder in the tests is worth a look. It gives every text three numbers, [space, crime, love]. Each starts at 0.1 and gains 1 for each keyword it finds in that group. So "An astronaut stranded on Mars" becomes about [2.1, 0.1, 0.1]. That is enough to check the ranking, the batch call and the "no call in similar" rule without paying for a real model.

31.3 Where to look in the code

PartSpring BootNode.jsPython
Index, precompute, search, similarMovieService.javamovieIndex.jsmovie_index.py
Cosine similarityVectorMath.javamovieIndex.jsmovie_index.py
HTTP layer and status codesMovieController.javaapp.jsmain.py
Film and result shapesMovie.java, MovieMatch.javamovieIndex.jsmovie_index.py
The 30 filmsmovies.jsonmovies.jsonmovies.json
Key and model nameapplication.yml.env.example.env.example
Index tests with a fake embedderMovieServiceTest.javamovieIndex.test.jstest_movie_index.py
HTTP testsMovieControllerTest.javaapp.test.jstest_api.py
Build filepom.xmlpackage.jsonrequirements.txt

The tests check the claims this note makes: the catalogue goes out in one batch, a search embeds only the query, space films rank above the others for a space query, similar leaves the film out and makes no model call, and a failed batch is tried again on the next request.

31.4 What this demo leaves out on purpose

ShortcutWhat a real system does
30 films, all sent in one batchSends a big catalogue in batches of a few hundred, to stay under the API's request limits
Vectors kept in memory, lost on restartSaves them in a file, a database table or a vector database
Every search scores all 30 filmsUses a vector index so search stays fast with millions (notes 9 and 10)
Titles matched exactly, ignoring caseUses film ids, and close-match search for typed titles
Only the description is embeddedOften embeds title, genre, cast and plot together, or keeps several vectors per film
Content similarity is the only signalMixes in history, ratings, popularity and business rules (section 25)
No login and no rate limitPuts the API behind authentication and limits calls per user, because every search costs money

Key Takeaways

  • An embedding model turns text into one fixed length vector. A chat model writes text token by token. An embedding model cannot answer a question.
  • "Recommend a film" can mean "from everything" (a chat model may know) or "from our own catalogue" (that needs a search over our data).
  • Pasting the catalogue into a prompt fails at scale. It does not fit, it repeats the same work on every request, it asks for writing when we need a ranking, and it is slow and costly.
  • Split stable data from changing data. Films rarely change, so embed them once in a batch and keep the vectors. Embed only the query per search.
  • Re-embed a film when its description changes, and everything when you change the embedding model.
  • Search by meaning finds "The Martian" for "a lost astronaut trying to get home" even with no shared words. Keyword search can rank Toy Story first.
  • Ranking is cosine with every stored vector, a sort, and the top K.
  • "More like this" reuses the film's stored vector. No embedding call, no tokens, and the film itself is left out.
  • Search and recommendation are one machine. Only the starting vector changes.
  • What we built is content based. Collaborative filtering uses what people did instead, and real systems mix many signals.
  • The pattern is represent, store, compare, rank. It works for products, tickets, jobs, recipes and code, not only films.

In this topic

Done with this topic?

How well do you know it?

PreviousVector Embeddings from First Principles