Embeddings for search
- Lesson 7
- 50 min
- Module 3: Build and compare retrieval
Lesson 7 of 18 · RAG: Building AI That Uses Your DataEmbeddings for search
1. Define the knowledge problem
2. Ingest documents reproducibly
3. Build and compare retrieval
4. Construct grounded answers
5. Evaluate and debug the pipeline
6. Maintain and protect the knowledge base
What you will be able to do
Find a passage that means the same as the question, even when it uses other words. Then make sure that the index and the question always go through the same embedding model, because a mix gives wrong results and no error.
Before you startWhat to know and to have ready
What you need to know
- Keyword search with BM25 and the measure hit@5, from A keyword-search baseline.
- Chunks, chunk IDs and the index file
index.sqlite, from Chunk deliberately and Preserve metadata. - Optional: vectors, the dot product and cosine similarity, from Similarity and distance in Essential Mathematics and Statistics for AI. This lesson explains what it needs.
What you need on your computer
- The
policy-assistantproject at the end of Module 2, in~/projects/policy-assistant, with.venvactive.python -m pytestshows31 passed. - If your project is broken or missing, copy the snapshot
end-of-m02in the same way asstartin Set up the project, and make a new.venv. - The labs repository in
~/projects/nextia-learning-labs. You copy some files from its snapshotend-of-m03. - About 2 GB of free disk space. The new Python packages take about 0.9 GB, and the local model about 0.5 GB.
No account and no key. The embedding model runs on your computer. Tested with Python 3.12.13 and sentence-transformers 6.1.0 on macOS (Apple silicon). The Windows and Linux commands are not tested.
Models change fast. The numbers in this module are a snapshot. The method is what lasts.
A librarian who understands you
Section titled “A librarian who understands you”You ask a librarian for “a book about fixing a bicycle”. She brings you Cycle Maintenance and Repair. The title has none of your words, but it is the right book. The librarian knows what the words mean, so your words and the book’s words do not need to match.
A library catalogue that only matches words would not find that book. It would find books with “bicycle” or “fixing” in the title, and miss the best one.
The problem: the words do not match
Section titled “The problem: the words do not match”Your keyword search from Module 2 is the catalogue. It finds a chunk only when the chunk has the words of the question. Ask it Grace’s question about a garden sprinkler:
python -m policy_assistant search "How long is the warranty on a garden sprinkler?" --k 20The answer is in the warranty table of warranty-policy, in the row “Watering | hoses, hose reels, sprinklers, timers | 1 year”. In the output, that chunk comes 17th. (The next lesson adds the date filter, which removes one chunk that is not in force; then the table is 16th.) The first result is the garden furniture warranty, because it has “garden” and “warranty”:
1. 7.3530 [ce196c57771f] product-care-garden-furniture v2 | Warranty | public Warranty Your garden furniture has a 2-year warranty against defects. Cushions and fabric covers ha…...17. 3.4516 [d9a5bd8ea531] warranty-policy v5 | Warranty periods by product c… | public Warranty periods by product category Product category | Examples | Warranty period | Exceptions Han…The question says “sprinkler”, and the table says “sprinklers”. For SQLite’s full-text search, these are two different words. A French question fails in the same way. Camille asks “Puis-je retourner une carte-cadeau?” (Can I return a gift card?), and the answer is in the English document gift-cards. The top 5 holds only French chunks about returns and refunds, and no chunk about gift cards. The words of the question and the words of the answer never meet.
The idea: a list of numbers for the meaning
Section titled “The idea: a list of numbers for the meaning”The librarian keeps a picture in her head of what each book is about. A computer can do something similar. An embedding model reads a text and returns a fixed-length list of numbers, called an embedding or a vector. The model was trained so that texts with a similar meaning get similar lists. “A refund for a broken kettle” and “money back for a faulty appliance” get close vectors, although they share no important word.
The deep learning course shows how a network learns such numbers, in Tokens and embeddings. You do not need it for this lesson. Here, the embedding model is a tool: text goes in, 384 numbers come out.
Search with embeddings has two steps. At ingest, the model embeds every chunk once, and the index stores the vectors. At search, the model embeds the question, and the code finds the chunk vectors that point in the most similar direction. This is vector search. It is also called dense retrieval, because almost every one of the 384 numbers is non-zero. A keyword index is “sparse”: each chunk uses only a few words of the whole vocabulary.
Similarity on a tiny example
Section titled “Similarity on a tiny example”How do you measure “similar direction”? Start with vectors of two numbers, so you can see them. These vectors are made up for this example. Pretend that the first number says how much a text is about money back, and the second how much it is about an appliance.
| Text | Vector |
|---|---|
| Question: “a refund for a broken kettle” | q = (4, 3) |
| A: “money back for a faulty appliance” | a = (3, 4) |
| R: “refunds go to the original payment method” | r = (5, 0) |
| K: “fill the kettle with fresh water” | k = (0, 5) |
Text description of the chart
Four arrows start at (0, 0), and each has a length of 5. The question q goes to (4, 3). Text a goes to (3, 4), just above q: the angle between them is small. Text r goes to (5, 0), along the horizontal axis “about money back”. Text k goes to (0, 5), up the vertical axis “about an appliance”. The angle between q and r is smaller than the angle between q and k.
The dot product multiplies the numbers in the same position and adds the results. For q and a: 4 × 3 + 3 × 4 = 24. For q and r: 4 × 5 + 3 × 0 = 20. For q and k: 4 × 0 + 3 × 5 = 15. A longer text could have bigger numbers, and so a bigger dot product, without a closer meaning. So we divide by the two lengths. Each vector here has the length 5 (for q: √(4² + 3²) = 5), so we divide by 5 × 5 = 25.
| Pair | Dot product | ÷ 25 = cosine similarity |
|---|---|---|
| q and a | 24 | 0.96 |
| q and r | 20 | 0.80 |
| q and k | 15 | 0.60 |
The result is the cosine similarity: the cosine of the angle between two vectors. It is 1 when the two arrows point the same way, and 0 when they are at a right angle. Text a wins, as the librarian would expect. In general:
Read it as: the dot product of the question vector and the passage vector, divided by their two lengths. The project’s embed.py scales every vector to length 1 first. Then the dot product is the cosine similarity, and one matrix multiplication compares the question with every chunk.
The real model on four sentences
Section titled “The real model on four sentences”Larkfield’s assistant uses intfloat/multilingual-e5-small, an open model with an MIT licence. It returns 384 numbers for a text, runs on a laptop CPU, and reads French as well as English. We call it e5 from here. Amira gave it the question and four sentences that she wrote for this lesson. The numbers are e5’s real cosine similarities:
| Sentence (written for this lesson) | Shares a word with the question? | Cosine similarity |
|---|---|---|
| We give your money back for a faulty electrical appliance. | no | 0.834 |
| Refunds are sent to the original payment method. | "refund" | 0.816 |
| Fill the kettle with fresh water before each use. | "kettle" | 0.813 |
| Garden furniture has a two-year warranty. | no | 0.764 |
The sentence with the same meaning and no shared word comes first, as with the made-up vectors. Two things are different from the tiny example. First, all four numbers are close together, from 0.76 to 0.84. With e5, even unrelated texts score about 0.7 or more. So a single number like 0.81 says little. The order is what counts. Second, the gaps are small: 0.834 for the best sentence, 0.816 for the next one. Vector search gives a ranking, not a yes or a no.
The e5 model has one more rule. It was trained with a label in front of each text: query: before a question, and passage: before a text to search. The project adds them in embed.py. Without them, the search still works, but less well. Over the 59 questions with a relevant passage, dense search with e5 has a hit@5 of 0.915 with the labels and 0.864 without them. Read a model’s card before you use it: these rules are written there.
Larkfield’s sprinkler question, two models
Section titled “Larkfield’s sprinkler question, two models”Is e5 good enough, or should Larkfield pay for a hosted model? The course also recorded embed-small: OpenAI’s text-embedding-3-small, deployed on Azure under that name. It returns 1,536 numbers per text. Its vectors for every chunk and question were, so you can use them without an account.
Here is the sprinkler question compared with all 185 chunks, by both models. Each dot is one chunk.
- Relevant: the warranty table
- The garden furniture warranty
- Other chunks
Text description of the chart
Each of the 185 dots is one chunk. The horizontal position is its cosine similarity to the question with e5, from 0.71 to 0.86. The vertical position is its similarity with embed-small, from 0.03 to 0.54. The dots form a rising cloud: a chunk that one model finds close, the other model usually finds close too. Two chunks sit at the top right. The garden furniture warranty has e5 0.864 and embed-small 0.523. The warranty table has e5 0.854 and embed-small 0.541. So e5 ranks the furniture warranty first and the table second; embed-small ranks the table first. The table has a second chunk, its last line about products that fit two categories. It is lower: rank 20 for e5 and rank 5 for embed-small.
Two lessons are in this picture. The scales differ: e5’s numbers are all between 0.71 and 0.86, and embed-small’s between 0.03 and 0.54. So never compare a score from one model with a score from another. And both models put the right chunk near the top: e5 ranks it second, embed-small first. On this one question, embed-small is a little better.
One question proves nothing. Priya ran dense search over the whole question set, with the filters of the next lesson:
| Model | hit@5 | Runs where | Cost to embed the collection | Time for one question |
|---|---|---|---|---|
| e5 (local) | 0.915 | your computer | free, 1.1 s | 5.2 ms |
| embed-small (hosted) | 0.881 | Azure | US$0.0005 for all 443 course texts | a network call |
The local model is as good as the hosted one on Larkfield’s questions. It is better on the French ones: all 8 French questions found a relevant passage with e5. The difference of 2 questions in 59 is not proof that e5 is better. And embed-small has one real strength: it puts a relevant passage first more often, for 80% of the questions against 68% for e5. It is enough to say that the hosted model is not needed here. And with e5, no policy text leaves Larkfield’s computers. The course also tried two other open models. all-MiniLM-L6-v2 is 5.4 times smaller and twice as fast. But it reads English only, and its hit@5 on the French questions is 0.625, against 1.000 for e5.
When to use embeddings, and what they cost
Section titled “When to use embeddings, and what they cost”Use embeddings when the questions use other words than the documents: paraphrases, synonyms, plural and singular, another language. Larkfield’s agents ask in their own words, and Camille asks in French, so the assistant needs them.
Do not expect them to find exact codes. An embedding model breaks “LB-1” into small pieces of text and has no special meaning for it. The next lesson shows this, with a real failure.
What they cost:
- Download and memory. e5 is 470 MB on disk, plus about 0.9 GB of Python packages (PyTorch and sentence-transformers).
- Time. Embedding all 185 chunks takes about 1.1 s on the test computer. One question takes about 5.2 ms, and comparing it with 185 vectors takes 0.09 ms.
- A rebuild for every change of model. Vectors from two models cannot be compared. A new model, or a new version of the same model, means that every chunk must be embedded again.
Build it: vectors in the index
Section titled “Build it: vectors in the index”The project gets two new files, dense.py and embed.py, a new version of store.py, and a change in the ingest command. Work in ~/projects/policy-assistant, with .venv active.
-
Copy the new
requirements.txtfrom the snapshotend-of-m03:Terminal window cp ../nextia-learning-labs/C09/snapshots/end-of-m03/requirements.txt .Terminal window Copy-Item ..\nextia-learning-labs\C09\snapshots\end-of-m03\requirements.txt .It adds
numpy,openai,sentence-transformers,torch,transformersandhuggingface-hub, with exact versions. -
On Linux only, install the CPU-only PyTorch first. Without this step, pip downloads the much larger version for NVIDIA graphics cards:
Terminal window python -m pip install torch==2.14.1 --index-url https://download.pytorch.org/whl/cpu -
Install the requirements. This can take a few minutes:
Terminal window python -m pip install -r requirements.txt -
Make the file
policy_assistant/dense.py. It is the vector search of this lesson, with NumPy:policy_assistant/dense.py """Vector search with NumPy: compare the question's vector with every chunk's vector (brute force).The vectors are normalised to length 1, so the dot product of two vectors is their cosine similarity:1 = same direction (same meaning, for the model), 0 = unrelated. With a few hundred chunks, comparingwith every chunk takes well under a millisecond; a vector index (for example sqlite-vec, FAISS) onlypays off with many thousands of chunks."""from typing import Callableimport numpy as npfrom .lexical import Hitdef normalise(vectors: np.ndarray) -> np.ndarray:vectors = np.asarray(vectors, dtype=np.float32)norms = np.linalg.norm(vectors, axis=-1, keepdims=True)return vectors / np.where(norms == 0, 1, norms)def cosine(a: np.ndarray, b: np.ndarray) -> float:a, b = normalise(a), normalise(b)return float(a @ b)def search(query_vector: np.ndarray, matrix: np.ndarray, ids: list[str], k: int = 5,allowed: Callable[[str], bool] | None = None) -> list[Hit]:"""The k chunks whose vectors are most similar to the query vector."""scores = normalise(matrix) @ normalise(query_vector)order = sorted(range(len(ids)), key=lambda i: (-scores[i], ids[i]))hits = [(ids[i], float(scores[i])) for i in order if allowed is None or allowed(ids[i])]return [Hit(i, round(s, 4), rank) for rank, (i, s) in enumerate(hits[:k], 1)]Part Meaning normaliseDivides each vector by its length, so every vector has the length 1. matrix @ query_vectorOne dot product per chunk: 185 cosine similarities at once. sorted(..., key=(-score, id))Highest score first. Equal scores are ordered by chunk ID, so the result is always the same. allowedA function that says if a chunk may be returned. The next lesson uses it for the filters. HitThe same result type as keyword search, from lexical.py. -
Copy
embed.py, the newstore.pyand the recorded embed-small vectors from the snapshot:Terminal window cp ../nextia-learning-labs/C09/snapshots/end-of-m03/policy_assistant/embed.py policy_assistant/cp ../nextia-learning-labs/C09/snapshots/end-of-m03/policy_assistant/store.py policy_assistant/mkdir -p recordingscp ../nextia-learning-labs/C09/snapshots/end-of-m03/recordings/embeddings_embed-small.npz recordings/Terminal window Copy-Item ..\nextia-learning-labs\C09\snapshots\end-of-m03\policy_assistant\embed.py policy_assistant\Copy-Item ..\nextia-learning-labs\C09\snapshots\end-of-m03\policy_assistant\store.py policy_assistant\New-Item -ItemType Directory -Force recordingsCopy-Item ..\nextia-learning-labs\C09\snapshots\end-of-m03\recordings\embeddings_embed-small.npz recordings\Open
embed.pyand read the classLocalEmbedder. These are its most important lines:policy_assistant/embed.py (part) LOCAL_MODEL = "intfloat/multilingual-e5-small"LOCAL_REVISION = "614241f622f53c4eeff9890bdc4f31cfecc418b3" # the model's Hugging Face commit, 2026-10-09PREFIXES = { # (query prefix, passage prefix) that each model was trained with"intfloat/multilingual-e5-small": ("query: ", "passage: "),}def embed_queries(self, texts: list[str]) -> np.ndarray:return self._encode([PREFIXES.get(self.name, ("", ""))[0] + t for t in texts])def embed_passages(self, texts: list[str]) -> np.ndarray:return self._encode([PREFIXES.get(self.name, ("", ""))[1] + t for t in texts])The revision is the exact commit of the model on Hugging Face, the site that hosts it. A model name alone can point to a newer version tomorrow. The revision cannot.
RecordedEmbedder("embed-small")gives the recorded hosted vectors. It finds a vector by a hash of the model name and the text, so it works only for the course’s chunks and questions.In
store.py, the new parts are a tablevectors, one row per chunk, and two lines insync()that record the model:policy_assistant/store.py (part) CREATE TABLE IF NOT EXISTS vectors (chunk_id TEXT PRIMARY KEY, vector BLOB);...if embedder is not None:settings |= {"embedding_model": embedder.name, "embedding_revision": embedder.revision} -
Open
policy_assistant/__main__.py. Replace the functioncmd_ingestwith these two functions:policy_assistant/__main__.py def make_embedder(name: str):if name == "none":return Nonefrom .embed import make_embedder as makereturn make(name)def cmd_ingest(args) -> None:from .store import Storepath = Path(args.index)store = Store(path)report = store.sync(CORPUS / "documents", args.chunker, make_embedder(args.embedder))print(report.line())meta = store.meta()print(f"index {path.name}: {len(store.documents())} documents, {len(store.chunks())} chunks | chunker {meta.get('chunker')} | "f"embeddings {meta.get('embedding_model', 'none')}") -
In the same file, find the line that adds
--chunkertoingest. Add the option--embedderbelow it:policy_assistant/__main__.py s.add_argument("--chunker", default="structure", choices=["fixed", "overlap", "structure"])s.add_argument("--embedder", default="intfloat/multilingual-e5-small",help="a sentence-transformers model, embed-small (recorded vectors) or none") -
Build the index again. The first time, the model downloads about 470 MB into
~/.cache/huggingface:Terminal window python -m policy_assistant ingestThe output ends with:
36 documents, 185 chunks (rebuilt from scratch)index index.sqlite: 36 documents, 185 chunks | chunker structure | embeddings intfloat/multilingual-e5-smallBefore these lines, you also see a progress bar,
Loading weights, and possibly a warning about unauthenticated requests to the HF Hub. The warning is harmless: the model is public, and no account is needed. -
Run the tests. They still pass, because nothing else changed:
Terminal window python -m pytestThe last line is
31 passed.
Guided practice: measure similarity in Python
Section titled “Guided practice: measure similarity in Python”-
Start Python with
pythonin the project folder. The prompt changes to>>>. -
Embed the question and the four sentences of this lesson, and print each similarity:
Python, in ~/projects/policy-assistant from policy_assistant.dense import cosinefrom policy_assistant.embed import LocalEmbeddere5 = LocalEmbedder()question = e5.embed_queries(["a refund for a broken kettle"])[0]print(len(question), question[:4].round(3))sentences = ["We give your money back for a faulty electrical appliance.","Refunds are sent to the original payment method.","Fill the kettle with fresh water before each use.","Garden furniture has a two-year warranty.",]for text, vector in zip(sentences, e5.embed_passages(sentences)):print(f"{cosine(question, vector):.3f} {text}")Press Enter on the empty line after the
forloop. The output:384 [ 0.06 -0.046 -0.019 -0.097]0.834 We give your money back for a faulty electrical appliance.0.816 Refunds are sent to the original payment method.0.813 Fill the kettle with fresh water before each use.0.764 Garden furniture has a two-year warranty.The question is now 384 numbers. The first four are small numbers around 0, as in most embeddings.
-
Compare the two models on the sprinkler question.
d9a5bd8ea531is the warranty table, andce196c57771fis the garden furniture warranty:Python (continued) from policy_assistant.embed import RecordedEmbedderfrom policy_assistant.store import Storestore = Store("index.sqlite")table = store.chunk("d9a5bd8ea531").textfurniture = store.chunk("ce196c57771f").textquestion = "How long is the warranty on a garden sprinkler?"for model in (e5, RecordedEmbedder("embed-small")):q = model.embed_queries([question])[0]t, f = model.embed_passages([table, furniture])print(f"{model.name}: {len(q)} numbers | table {cosine(q, t):.3f} | furniture {cosine(q, f):.3f}")The output:
intfloat/multilingual-e5-small: 384 numbers | table 0.854 | furniture 0.864embed-small: 1536 numbers | table 0.541 | furniture 0.523 -
Look at what the index remembers:
Python (continued) print(store.meta())The output (your time is different):
{'chunker': 'structure', 'updated_at': '2026-10-09T05:52:44+00:00', 'embedding_model': 'intfloat/multilingual-e5-small', 'embedding_revision': '614241f622f53c4eeff9890bdc4f31cfecc418b3'}Stay in Python for the next task, or leave with
exit().
Your turn: a French sentence
Section titled “Your turn: a French sentence”Change the second sentence of step 2 to French: “Nous remboursons un appareil électrique défectueux.” (We refund a faulty electrical appliance.) The question stays in English. Before you run it, predict: where does the French sentence rank among the four, and why?
Show the answer
0.834 We give your money back for a faulty electrical appliance.0.819 Nous remboursons un appareil électrique défectueux.0.813 Fill the kettle with fresh water before each use.0.764 Garden furniture has a two-year warranty.The French sentence ranks second, above the English sentence about the kettle. The e5 model is multilingual: it learned from text in many languages. So a French sentence and an English sentence with the same meaning get close vectors. That is why Camille’s French questions can find English documents. Keyword search cannot do this, because “remboursons” and “refund” are different words.
Common mistake
Two models, one index
Symptom: Tomás wants a faster model for the questions: all-MiniLM-L6-v2 is twice as fast as e5, and it also returns 384 numbers. He embeds the sprinkler question with it and searches the e5 index. Python shows no error, and five results come back:
from policy_assistant.dense import search
minilm = LocalEmbedder("sentence-transformers/all-MiniLM-L6-v2")ids, matrix = store.vectors()q = minilm.embed_queries([question])[0]print(len(q), matrix.shape)for hit in search(q, matrix, ids): c = store.chunk(hit.id) print(hit.rank, hit.score, c.doc_id, "|", c.section)384 (185, 384)1 0.0582 refund-approval-procedure | Rules2 0.0512 damaged-or-wrong-items | After 14 days3 0.049 damaged-or-wrong-items | Report within 14 days4 0.0484 trade-accounts | Returns for trade customers5 0.0469 refunds-and-payments | Double chargesNone of the five is about a warranty or a sprinkler. Over the whole question set, this mix has a hit@5 of 0.068: 4 questions in 59, against 0.915 with e5 alone. (This code downloads all-MiniLM-L6-v2, about 90 MB. You do not need to run it.)
Cause: each model has its own “map of meaning”. Number 17 in an e5 vector and number 17 in a MiniLM vector mean different things, although both lists have 384 numbers. The code can multiply them, so it does, and the result is noise.
Fix: store the model name and its revision with the vectors, and check them before every search. The index already records them in meta. The next lesson adds search.py, which compares them and stops with an IndexMismatch error. To change the model, build the index again: python -m policy_assistant ingest --embedder NAME.
Text description of the diagram
At ingest, e5 reads every one of the 185 chunks with the label “passage: “ in front. It writes 185 vectors of 384 numbers each into the index. It also writes the model name and its revision into the table meta. At search, a model reads the question with the label “query: “ and makes one vector. Before any comparison, a check asks: is it the same model, with the same revision? If yes, the question vector is compared with the 185 vectors by cosine similarity, and the top 5 come back. If no, the search stops with the error IndexMismatch. A dashed path shows what happens without the check: results come back with no error, but the hit@5 is 0.068.
For a real AI service: an embedding index is tied to one model version. Record the version with the index. Then a model update, a configuration mistake or a second service with another model fails loudly, instead of returning wrong passages that look normal.
Knowledge check
Section titled “Knowledge check”Check your understanding
In your own words: what does an embedding model do, and how does vector search use it to find a passage?
Show the answer
An embedding model reads a text and returns a fixed-length list of numbers, a vector. It was trained so that texts with a similar meaning get vectors that point in a similar direction. At ingest, it embeds every chunk once, and the index stores the vectors. At search, it embeds the question. Then the code computes the cosine similarity between the question vector and every chunk vector, and returns the chunks with the highest values. So a passage can be found by its meaning, even when it uses other words than the question.
Does your answer have the main points of ours?
Sign in and enroll in the course to save your answers. Every check counts toward the certificate.
Check your understanding
Predict: two vectors are (1, 0) and (0, 1). What is their cosine similarity? And for (2, 2) and (1, 1)?
Show the answer
For (1, 0) and (0, 1), the dot product is 1 × 0 + 0 × 1 = 0. So the cosine similarity is 0: the arrows are at a right angle. For (2, 2) and (1, 1), the dot product is 4. The lengths are √8 and √2, and √8 × √2 = 4. So the cosine similarity is 4 ÷ 4 = 1: the arrows point the same way, although one is twice as long. Cosine similarity looks only at the direction.
Does your answer have the main points of ours?
Sign in and enroll in the course to save your answers. Every check counts toward the certificate.
Check your understanding
Omar asks: 'The e5 score of the furniture warranty is 0.864 and the embed-small score of the table is 0.541. So e5 is more sure, right?' What do you answer?
Show the answer
No. The two models use different scales. With e5, almost every chunk scores between 0.7 and 0.9, and with embed-small most chunks score much lower. A number from one model cannot be compared with a number from the other. Only the order within one model means something: e5 puts the furniture warranty first and the table second, embed-small puts the table first. To compare the models, measure both on the same questions, for example with hit@5.
Does your answer have the main points of ours?
Sign in and enroll in the course to save your answers. Every check counts toward the certificate.
Recap
- An embedding model turns a text into a vector, a fixed-length list of numbers. Texts with a similar meaning get vectors with a similar direction. Vector search, or dense retrieval, compares the question’s vector with every chunk’s vector.
- Cosine similarity measures the direction: the dot product divided by the two lengths. With vectors of length 1, it is the dot product. Only the order of the scores counts; their size depends on the model.
- The local model e5 needs the labels
query:andpassage:. On Larkfield’s questions it has a hit@5 of 0.915, against 0.881 for the hosted embed-small. It is free, and no text leaves the computer. - The index stores its model name and revision. Vectors from two models cannot be compared: a mix gave a hit@5 of 0.068, with no error.
Next, put keyword search and vector search side by side on four kinds of questions, combine them, and add the filters for dates and access.