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:
| Word | What it means here |
|---|---|
| Embedding model | A model that turns text into a fixed length list of numbers. It never writes text |
| Chat model | A model that writes text one token at a time. Also called a generative LLM |
| Catalogue | Every item an app has. Here, every film on our platform |
| Keyword search | Search that only finds items that contain the same words you typed |
| Semantic search | Search by meaning. The words can be different as long as the idea is the same |
| Query vector | The embedding of what the user typed into the search box |
| Precompute | Do slow or costly work once, save the result, and reuse it |
| Batch call | One API request that carries many inputs at the same time |
| Cosine similarity | A score from -1 to 1 for how much two vectors point the same way (note 7) |
| Top K | The K best scoring items. K is just a number you choose, like 3 |
| Content based recommendation | Suggest items whose content is like something you already liked |
| Collaborative filtering | Suggest 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.
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.
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.
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.05It 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.
The left side loops. The right side runs once and stops.
| Chat model | Embedding model | |
|---|---|---|
| Input | Text | Text |
| Output | Text, written one token at a time | One fixed length vector |
| Can it explain or chat? | Yes | No |
| Output size | Changes with the answer | Always the same |
| How many steps | One per output token | One |
| Price per token | Higher | Much lower |
| Good for | Writing, summarising, answering | Search, 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.
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?".
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:
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:
| Catalogue | Tokens in the prompt |
|---|---|
| 10 films | 500 |
| 1,000 films | 50,000 |
| 8,000 films | 400,000 |
| 100,000 products in a shop | 5,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.
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.
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.32A score per film, then a sort. That is a ranking problem, not a writing problem.
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.
The left box repeats a huge job per search. The right box sends one short line and does the rest locally.
| Per search | Naive plan | Embedding plan |
|---|---|---|
| Input tokens | About 400,000 | About 10 |
| Output tokens | Around 50 to 100 | None, just a vector |
| Model calls | One big chat call | One small embedding call |
| Work on our server | Very little | 8,000 cosine scores, a few milliseconds |
| Answer shape | Text we must parse | Scores 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.
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.
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?".
Not every problem needs a model at all. "Films from 2016" is a database filter, not an AI task.
| Task | Operation | Tool |
|---|---|---|
| Summarise this support ticket | Write new text | Chat model (note 3) |
| Write a polite reply email | Write new text | Chat model |
| Find films like this description | Find nearest items | Embeddings + cosine |
| Group similar complaints | Find nearest items | Embeddings + cosine |
| List films released after 2010 | Filter by a field | Database query |
| Add up an order total | Arithmetic | Plain 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.
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.
16. The Project: Reelbox Search
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:
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 homeFirst, 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.
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).
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].
| Item | Vector |
|---|---|
| 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.988Do the same for the rest and sort:
| Rank | Film | Cosine |
|---|---|---|
| 1 | The Martian | 0.988 |
| 2 | Gravity | 0.965 |
| 3 | Interstellar | 0.935 |
| 4 | Ocean's Eleven | 0.331 |
| 5 | Notting Hill | 0.237 |
Every score in that table came from the same few steps:
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.
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.
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.
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:
| Rank | Film | Cosine with Gravity |
|---|---|---|
| 1 | Interstellar | 0.995 |
| 2 | The Martian | 0.990 |
| 3 | Notting Hill | 0.459 |
| 4 | Ocean's Eleven | 0.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:
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:
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."
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 based | Collaborative filtering | |
|---|---|---|
| Looks at | What the item is about | What people did |
| Needs | Item descriptions | Lots of user history |
| Brand new film, nobody has watched it | Works on day one | Has nothing to go on |
| Can surprise you with something different | Rarely | Often |
| Our demo | Yes | No |
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:
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.
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.
- 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.
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.
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.
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:
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.
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:
| Endpoint | What it does |
|---|---|
GET /api/movies/search?q=...&k=3 | Films whose description is closest in meaning to q |
GET /api/movies/similar?title=...&k=3 | Films 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:
| Status | When |
|---|---|
| 200 | Results, best first |
| 400 | q or title is empty, or k is not a whole number from 1 to 10 |
| 404 | No film with that title |
| 502 | The embedding API failed |
The checks run in this order:
Every check that can fail without spending money runs first. Only then does the service call the API.
Decisions worth copying:
| Decision | Why |
|---|---|
| The whole catalogue is embedded in one batch call | One round trip instead of 30, and the vectors come back in the same order |
| Only the description is embedded | Users describe stories, so we compare stories. Titles and genres are shown, not searched |
| The index is built once and kept | Films rarely change. Queries change all the time (section 12) |
similar reuses the stored vector | No embedding call, no tokens, nothing that can fail upstream |
The film itself is left out of similar | Its score with itself is always 1 |
| The response has no vectors | 1,536 numbers per film would be useless bytes for the caller |
| A batch with the wrong number of vectors is an error | We could not tell which vector belongs to which film |
| A failed batch is not cached | The next request can try again instead of failing forever |
| Bad input is a 400 before any API call | An empty query would still cost a call and match nothing useful |
| The OpenAI client is passed in, not created inside | Tests 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:
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:
| Concern | Spring AI | Node.js (openai SDK) |
|---|---|---|
| The model object | EmbeddingModel, built by Spring from application.yml | new OpenAI() in server.js |
| Embed many texts | embeddingModel.embed(List<String>) gives List<float[]> | client.embeddings.create({ model, input: [...] }) gives data[].embedding |
| Embed one query | embeddingModel.embed(String) gives float[] | the same call with a one item list |
| Model name | spring.ai.openai.embedding.options.model | OPENAI_EMBEDDING_MODEL in .env |
| When the index is built | In the MovieService constructor, at start-up | On the first request, then cached |
| Cosine | VectorMath.cosineSimilarity(float[], float[]) | cosineSimilarity(a, b) in movieIndex.js |
| Unknown title | Optional.empty() | null |
| HTTP layer | @RestController, @GetMapping, @RequestParam | Express app.get, request.query |
| Fake used in tests | A fake EmbeddingModel that maps keywords to 3 numbers | A 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
| Part | Spring Boot | Node.js | Python |
|---|---|---|---|
| Index, precompute, search, similar | MovieService.java | movieIndex.js | movie_index.py |
| Cosine similarity | VectorMath.java | movieIndex.js | movie_index.py |
| HTTP layer and status codes | MovieController.java | app.js | main.py |
| Film and result shapes | Movie.java, MovieMatch.java | movieIndex.js | movie_index.py |
| The 30 films | movies.json | movies.json | movies.json |
| Key and model name | application.yml | .env.example | .env.example |
| Index tests with a fake embedder | MovieServiceTest.java | movieIndex.test.js | test_movie_index.py |
| HTTP tests | MovieControllerTest.java | app.test.js | test_api.py |
| Build file | pom.xml | package.json | requirements.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
| Shortcut | What a real system does |
|---|---|
| 30 films, all sent in one batch | Sends a big catalogue in batches of a few hundred, to stay under the API's request limits |
| Vectors kept in memory, lost on restart | Saves them in a file, a database table or a vector database |
| Every search scores all 30 films | Uses a vector index so search stays fast with millions (notes 9 and 10) |
| Titles matched exactly, ignoring case | Uses film ids, and close-match search for typed titles |
| Only the description is embedded | Often embeds title, genre, cast and plot together, or keeps several vectors per film |
| Content similarity is the only signal | Mixes in history, ratings, popularity and business rules (section 25) |
| No login and no rate limit | Puts 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.