What you’ll learn
- ●What are embeddings?
- ●Embeddings API
- Similarity searchFree account
- ChromaDBFree account
- PineconeFree account
- pgvectorFree account
- RecapFree account
- ●Project: Semantic search engine
What are embeddings?
Imagine a giant map where every word or sentence gets placed somewhere based on its meaning — not its spelling. On this map, "dog" and "puppy" sit right next to each other, "dog" and "cat" sit a little further apart (still in the same "animals" neighbourhood), and "dog" and "laptop" sit on completely opposite sides of the map. That map is exactly what an embedding gives a computer.
In an embedding, a piece of text (a word, sentence, or whole paragraph) is converted into a list of numbers — a vector — that captures its meaning. In Python, that vector is just a list (or NumPy array) of floats, e.g. [0.023, -0.045, 0.089, ...], often 1536 numbers long. Two pieces of text with similar meaning get vectors that land close together in this "meaning space"; unrelated text lands far apart. The computer never "reads" the words — it just compares number patterns, and similar meaning shows up as similar numbers.
This is the difference between old-school keyword search and modern semantic search. Keyword search matches literal words — search "AI" and a document about "artificial intelligence" won't show up unless that exact word appears. Semantic search compares embeddings, so "artificial intelligence", "AI", and "machine learning" all land near each other in meaning-space and get matched even though the words are completely different.
🌍 Real-world example: On Amazon, searching "warm winter jacket" also surfaces listings titled "insulated coat for cold weather" — no shared words at all, but very close meaning. That's semantic search powered by embeddings, not a keyword match.
💡 Embedding = a list of numbers (a vector) that represents the meaning of a piece of text.
💡 Vector = an ordered list of numbers, e.g.
[0.2, -0.5, 0.8]. Each embedding is one vector.
💡 Meaning space = the imaginary multi-dimensional map where embeddings live; similar meanings sit close, different meanings sit far.
💡 Semantic search = finding results by meaning (comparing embeddings) instead of by matching exact keywords.
Standard definition: An embedding is a numerical vector representation of text that captures its semantic meaning, such that texts with similar meaning have vectors that are close together in vector space.
from openai import OpenAI
client = OpenAI()
def get_embedding(text):
"""Convert text into a meaning-vector using an embedding model."""
response = client.embeddings.create(
model="text-embedding-3-small",
input=text
)
return response.data[0].embedding
vector = get_embedding("I love Python for AI")
print(vector[:5]) # peek at first 5 numbers
print(len(vector)) # total dimensions in the vectorEmbeddings API
Think of an embedding model as a translator that converts any sentence into a fixed set of GPS coordinates on a giant 'meaning map'. You never read this map yourself — you just hand a sentence to the translator (the model) and it hands back a list of numbers: the coordinates. Sentences with similar meaning land on coordinates that are close together on that map.
In Python, this translator is the embeddings.create() method on the OpenAI client. You give it model (which translator to use, e.g. text-embedding-3-small) and input (your text), and it returns a response object holding the vector — the coordinates. Every vector from a given model always has the same length: text-embedding-3-small always returns 1536 numbers, whether your sentence is 3 words or 300 words. That fixed size is exactly what lets a vector database compare millions of sentences instantly.
You rarely embed one sentence at a time in real apps — you pass a list of texts as input, and one API call returns one vector per text, in the same order you sent them. This 'batching' is faster and cheaper than looping and calling the API once per sentence.
🌍 Real-world example: A search engine embeds 10,000 product descriptions once (in batches) and stores the vectors. When a user searches "warm winter jacket", it embeds just that one query and compares it to the stored 10,000 — no keyword matching, pure meaning matching.
💡 Embedding vector = the fixed-length list of numbers the model returns for a piece of text. 💡 Dimensions = how many numbers are in that list (1536 for
text-embedding-3-small). 💡 Batching = sending many texts in one API call instead of one call per text.
The single most important rule: always use the same embedding model for the documents you stored AND the query you search with. text-embedding-3-small and text-embedding-3-large place points on different maps — comparing a vector from one to a vector from the other is meaningless, even though both are just 'lists of numbers'. At scale, also remember embedding calls cost money and take time, so batching many texts per call (instead of one call per text) saves both.
Standard definition: An embedding API call sends text to an embedding model (e.g. OpenAI text-embedding-3-small) and returns a fixed-length numeric vector representing that text's meaning; the same model must be used to embed both the stored documents and the search query.
from openai import OpenAI
client = OpenAI() # reads your API key from the environment
texts = [
"Python is great for AI",
"JavaScript rules the web"
]
response = client.embeddings.create(
model="text-embedding-3-small",
input=texts # a LIST -> batched in one call
)
embeddings = [item.embedding for item in response.data]
print(f"Got {len(embeddings)} vectors")
print(f"Each vector has {len(embeddings[0])} dimensions")
print(f"First 5 values of vector 0: {embeddings[0][:5]}")Project: Semantic search engine
What we're building: A tiny but complete semantic search engine — a script that answers a question by meaning, not by matching exact words. You give it a small knowledge base (say, FAQ answers), and it finds the most relevant ones for any query — even if the query uses totally different words. This one script is the exact retrieval engine behind every RAG (Retrieval-Augmented Generation) app you'll ever build. In Chapter 4 you'll take the top results this script returns and hand them to an LLM to write a final answer — that's the whole trick of RAG.
Step 0 — The mental model (read first, 30 seconds)
Keyword search asks: "does this document contain these exact words?" — it will completely miss a document about "machine learning coding" when you search for "AI programming", even though they mean nearly the same thing.
Semantic search asks a different question: "does this document mean something close to what I'm asking?" It does this by turning every piece of text into an embedding — a list of numbers (a vector) that captures its meaning — and then measuring how close two vectors are in that "meaning-space". Close vectors = close meaning.
The whole engine is four beats, repeated:
- Embed every document once, up front — turn each doc's text into a vector.
- Store each
(text, vector)pair somewhere you can loop over later (a plain Python list is enough for a small project; a vector DB like ChromaDB takes over once you have millions of docs). - Embed the user's query the same way, using the same embedding model.
- Rank: compute cosine similarity between the query vector and every stored vector, sort, and return the top-k texts.
💡 Cosine similarity = a number between -1 and 1 (in practice, for text embeddings, roughly 0 to 1) measuring the angle between two vectors. 1 means "pointing the same direction" (same meaning), 0 means unrelated.
🌍 Real-world example: this exact loop — embed docs once, embed query, rank, return top-k — is what powers "search" in ChatGPT's custom GPTs, Notion AI's "ask your workspace", and every customer-support chatbot that answers from a company's help docs. They just swap the in-memory list for a real vector database at scale.
Step 1 — The knowledge base: a list of documents
documents = [
"To reset your password, go to Settings > Security and click 'Reset Password'.",
"Our refund policy allows returns within 30 days of purchase with a valid receipt.",
"You can upgrade to the Pro plan anytime from the Billing page in your dashboard.",
"The mobile app is available for both iOS and Android from their respective stores.",
"To cancel your subscription, go to Billing > Manage Plan > Cancel Subscription.",
"We support UPI, credit cards, and net banking for all payments in India.",
]
What's happening: documents is a plain Python list of strings — nothing fancier than that. In a real app these might be FAQ answers, support-ticket resolutions, or product doc chunks pulled from a database. The whole point of this project is: turn this list into something you can search by meaning.
Step 2 — Embed every document ONCE
import os
from dotenv import load_dotenv
from openai import OpenAI
load_dotenv()
client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])
response = client.embeddings.create(
model="text-embedding-3-small",
input=documents # batch — one API call for ALL documents
)
doc_vectors = [item.embedding for item in response.data]
What's happening: client.embeddings.create(model=..., input=documents) sends the entire list of documents in one HTTPS request — the API accepts a list, so you don't loop and call it six separate times (slower + costlier). The reply's .data is a list of result objects in the same order as documents, and item.embedding on each one is the actual vector — for text-embedding-3-small that's a list of 1536 floats. After this line, doc_vectors[i] is the meaning-vector for documents[i] — same index, same order, so you can always map one back to the other.
💡 Embedding model = the AI model whose only job is text → vector.
text-embedding-3-small(1536 dimensions) is OpenAI's cheap, solid default for this kind of project.
Crucial rule: you embed the documents once, up front, not on every search — that's the whole performance win. A real app would do this step at data-ingestion time and cache the vectors (in a file, a database column, or a vector DB); it would NOT re-embed all documents on every user query.
Step 3 — Store (text, vector) pairs
# Simplest possible "vector store": a Python list of tuples
vector_store = list(zip(documents, doc_vectors))
# vector_store[i] = (documents[i], doc_vectors[i])
What's happening: zip(documents, doc_vectors) pairs up each document with its own vector, position by position, and list(...) turns that into a list of (text, vector) tuples. This is a vector store — just the smallest possible one. A real vector store (ChromaDB, Pinecone, pgvector) does exactly this same job — hold (text/metadata, vector) together — but adds an index so it can find the nearest vectors among millions in milliseconds instead of looping through every single one.
💡 Vector database = a store built to hold huge numbers of vectors and find the nearest ones fast, using an approximate-nearest-neighbor (ANN) index like HNSW, instead of comparing against every vector one by one. For a handful of documents like this project, a plain list is genuinely fine — you only reach for ChromaDB/Pinecone/pgvector once you have thousands-to-millions of documents.
Step 4 — Embed the user's query (same model, always)
query = "How do I get my money back?"
query_response = client.embeddings.create(
model="text-embedding-3-small", # MUST match the model used for documents
input=query
)
query_vector = query_response.data[0].embedding
What's happening: Notice the query contains zero words in common with the matching document ("Our refund policy allows returns within 30 days...") — no "money", no "back", no overlap at all. Keyword search would find nothing. Semantic search will still find it, because both sentences are about the same thing — getting a refund — and their embeddings will land close together in meaning-space.
The rule that breaks everything if you skip it: the query MUST be embedded with the exact same model as the documents (text-embedding-3-small here, not -large or ada-002). Different embedding models place vectors in different, incompatible spaces — comparing a -small query vector against -large document vectors gives meaningless numbers, even though no error is raised.
Step 5 — Rank every document by cosine similarity
import numpy as np
def cosine_similarity(a, b):
a, b = np.array(a), np.array(b)
return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))
scored = []
for text, vector in vector_store:
score = cosine_similarity(query_vector, vector)
scored.append((score, text))
scored.sort(reverse=True) # highest similarity first
What's happening, traced: cosine_similarity(a, b) computes dot(a, b) / (norm(a) * norm(b)) — the dot product measures how much two vectors point the same way, and dividing by both norms (lengths) removes any effect of vector magnitude, leaving purely the angle between them. The for text, vector in vector_store: loop unpacks each stored tuple — this is the same tuple-unpacking pattern from Chapter 1 — and computes one similarity score per document against the single query_vector. scored ends up as a list of (score, text) tuples; scored.sort(reverse=True) sorts tuples by their first element by default, which is exactly the score, so the most similar document lands at index 0.
💡 Cosine similarity in one line:
dot(a, b) / (|a| * |b|)— same formula whether you write it by hand with NumPy or let ChromaDB/Pinecone/pgvector run it internally over an index.
Step 6 — Return the top-k most relevant documents
top_k = 2
top_results = scored[:top_k]
for score, text in top_results:
print(f"{score:.4f} -> {text}")
What's happening: scored[:top_k] slices the sorted list down to just the best top_k results — a plain Python list slice, nothing new. The loop unpacks each (score, text) pair and prints the similarity score alongside the matching document. This is the final output of the search engine: given "How do I get my money back?", the top result is the refund-policy document, ranked purely by meaning.
🔎 The full flow (recap the wiring, embed → store → query → rank → return)
documents— a plain list of strings, your knowledge base.client.embeddings.create(model=..., input=documents)— one batched API call embeds ALL documents once;doc_vectors[i]matchesdocuments[i]by index.vector_store = list(zip(documents, doc_vectors))— the simplest vector store:(text, vector)tuples in a list. (Swap this line for ChromaDB and nothing else in the pipeline changes.)client.embeddings.create(model=..., input=query)— embed the user's query with the exact same model used for the documents.cosine_similarity(query_vector, vector)in a loop overvector_store— score every document against the query.scored.sort(reverse=True)thenscored[:top_k]— rank and keep only the best matches.print(...)the top-k(score, text)results — done.
Every embeddings concept from this chapter shows up here: text → vector (Step 2/4), a vector store (Step 3), cosine similarity + ranking (Step 5/6). Swap vector_store for a real ChromaDB collection and the shape of this pipeline barely changes — you'd call collection.add(documents=..., ids=...) instead of building the list yourself, and collection.query(query_texts=[query], n_results=top_k) instead of the manual loop — ChromaDB does exactly Steps 2-3-5-6 internally, with an ANN index instead of a plain loop.
🚀 This IS the retrieval half of RAG
RAG (Retrieval-Augmented Generation) is exactly this pipeline, plus one more step: instead of just printing top_results, you'd join their text together and paste it into a prompt for an LLM — "Answer the user's question using ONLY this context: {top_results}. Question: {query}" — and let the LLM write a natural-language answer grounded in your real documents, instead of printing raw matches. Chapter 4 (RAG) starts exactly where this script ends: it takes top_results and feeds it into client.responses.create(...) from Chapter 1. Every piece you just built — embed, store, query, rank, top-k — is retrieval; only the final "ask an LLM to answer using these docs" step is new.
✅ What you just learned — and what's next
- Semantic search = embed once, embed query, cosine-rank, return top-k — four beats, and this project traced all four end to end.
- Documents are embedded once, up front — never re-embedded per query; that's the performance and cost win.
- The query MUST use the same embedding model as the documents, or the similarity scores become meaningless.
- A vector store is just
(text, vector)pairs — a Python list is a real (tiny) vector store; ChromaDB/Pinecone/pgvector are the same idea with an ANN index for scale. - Cosine similarity ranks by meaning, not by shared words — that's why "How do I get my money back?" correctly matched a refund-policy sentence with zero overlapping words.
- This is the retrieval half of RAG. Chapter 4 plugs
top_resultsstraight into an LLM prompt to generate a grounded, natural-language answer.
Standard definition: A semantic search engine embeds a fixed set of documents once into vectors using an embedding model, stores each vector alongside its original text, embeds an incoming query with that same model, ranks every stored document by cosine similarity to the query vector, and returns the top-k highest-scoring documents — the exact retrieval step that RAG systems feed into an LLM to generate grounded answers.
import os
import numpy as np
from dotenv import load_dotenv
from openai import OpenAI
load_dotenv()
client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])
# Step 1: the knowledge base
documents = [
"To reset your password, go to Settings > Security and click 'Reset Password'.",
"Our refund policy allows returns within 30 days of purchase with a valid receipt.",
"You can upgrade to the Pro plan anytime from the Billing page in your dashboard.",
"The mobile app is available for both iOS and Android from their respective stores.",
"To cancel your subscription, go to Billing > Manage Plan > Cancel Subscription.",
"We support UPI, credit cards, and net banking for all payments in India.",
]
# Step 2: embed every document ONCE (batched, one API call)
response = client.embeddings.create(model="text-embedding-3-small", input=documents)
doc_vectors = [item.embedding for item in response.data]
# Step 3: store (text, vector) pairs -- the simplest possible vector store
vector_store = list(zip(documents, doc_vectors))
def cosine_similarity(a, b):
a, b = np.array(a), np.array(b)
return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))
def semantic_search(query, top_k=2):
# Step 4: embed the query with the SAME model used for documents
query_vector = client.embeddings.create(
model="text-embedding-3-small", input=query
).data[0].embedding
# Step 5: rank every document by cosine similarity
scored = [(cosine_similarity(query_vector, vec), text) for text, vec in vector_store]
scored.sort(reverse=True)
# Step 6: return the top-k most relevant documents
return scored[:top_k]
query = "How do I get my money back?"
for score, text in semantic_search(query, top_k=2):
print(f"{score:.4f} -> {text}")Embeddings & Vector DBinterview questions & answers
10 sample questions below — 86+ in the full bank inside.
Why can't you read an embedding like normal text — i.e., why aren't embeddings human-interpretable?
Embeddings are lists of numbers (often hundreds or thousands of them) where each number's meaning is tied to a pattern learned by a machine learning model. There's no direct mapping between individual numbers and human concepts; they're abstract mathematical representations of meaning, not a human-readable encoding.
In simple terms: It's like looking at a painting done by an abstract artist using only numbers instead of colors — you can't point to a single brushstroke and say 'that means dog'. But a computer trained on the same model can compare two paintings and know they're similar. Example: [0.532, -0.214, 0.891, ...] tells the ML model the text is about 'basketball', but humans just see random decimals.
Why are embeddings represented as vectors (lists of numbers)?
Vectors of numbers allow computers to measure how similar two meanings are by calculating the distance between them. If two pieces of text have similar meanings, their vectors will be close together in space. This lets you do 'search by meaning' instead of just 'search by keywords'.
In simple terms: Imagine a map where each concept gets a location based on what it means. Closer places on the map are more similar in meaning. Example: 'happy' and 'joyful' would be plotted very close together on this conceptual map, while 'happy' and 'sad' would be far apart.
What is an embedding?
An embedding is a list of numbers (a vector) that captures the meaning of a piece of text. For example, the word 'king' might be represented as [0.5, -0.3, 0.8, ...] with hundreds of dimensions. Machine learning models create these vectors so that similar meanings end up close to each other in this number space.
In simple terms: Think of an embedding like a fingerprint for text — instead of storing the actual words, you store a unique pattern of numbers that describes what the text is really about. Example: the words 'car' and 'automobile' would get very different fingerprints (vectors), but their fingerprints would be much more similar to each other than either would be to the fingerprint for 'pizza'.
What does it mean when we say two embeddings are 'close' in meaning-space?
When two embeddings are close in meaning-space, it means the text they represent has similar or related meanings. The 'closeness' is measured mathematically (usually with cosine similarity or Euclidean distance). The closer two vectors are, the more alike their meanings are.
In simple terms: Think of a city where houses are arranged by topic — all 'animal' houses cluster together, all 'food' houses cluster together, and so on. If two houses are on the same block, they're talking about similar things. Example: embeddings for 'cat' and 'dog' sit very close together (both animals), but far from 'pizza' (food).
How do you create a ChromaDB client in Python?
You create a ChromaDB client using chromadb.Client(). By default, this creates an in-memory client, but you can also pass a persistent storage path to save vectors to disk.
In simple terms: Creating a ChromaDB client is like turning on your computer — it's your entry point to the database. Example: `import chromadb; client = chromadb.Client()` creates an in-memory client, or `chromadb.HttpClient(host='localhost', port=8000)` connects to a running ChromaDB server.
What is ChromaDB and why would you use it?
ChromaDB is a simple, embedded vector database designed for learning and small-scale applications. It lets you store vectors locally without needing a cloud service, making it ideal for prototyping semantic search or RAG systems quickly.
In simple terms: Think of ChromaDB like a personal library in your home — you can organize and find books instantly without needing to go to a big library. Real example: you could embed 100 product descriptions as vectors, store them in ChromaDB locally, and search for products by meaning in seconds without touching the internet.
What kind of metadata can you store alongside vectors in ChromaDB?
ChromaDB lets you store any JSON-serializable metadata (dictionaries, lists, strings, numbers, booleans) alongside each vector. Common examples include product IDs, titles, categories, timestamps, or any custom attributes relevant to your use case.
In simple terms: Metadata is like the labels on a file folder — it helps you understand and filter results later. Example: when storing a product vector, you store metadata like `{"product_id": 123, "name": "Laptop", "price": 50000, "category": "electronics"}` so when you find similar products, you already know what they are.
What is a collection in ChromaDB?
A collection is a container for storing vectors and their associated metadata or documents in ChromaDB. You create a collection with a name, add documents to it with IDs and metadata, and then query it to find similar vectors.
In simple terms: A collection is like a drawer in your file cabinet — all your documents of one type go into that drawer. For example, if you have a collection called 'product_descriptions', you add all your product vectors there, tag them with product IDs and prices, then search within that drawer for similar products.
What does 'top-k' mean when querying ChromaDB?
Top-k means returning the k nearest vectors that are most similar to your query vector. For example, top-3 returns the 3 most similar results, top-10 returns 10, and so on.
In simple terms: Top-k is like asking a search engine 'give me the 5 most relevant results' — you're asking for the top k results instead of all matches. Example: if you embed a sentence and set k=5, ChromaDB returns the 5 closest vectors (documents) ranked by similarity, ignoring all the rest.
What is cosine similarity and how is it different from Euclidean distance?
Cosine similarity measures the angle between two vectors (ranges 0 to 1; higher = more similar meaning). Euclidean distance measures straight-line distance (ignores direction). For embeddings, cosine similarity is preferred because it cares about direction, not magnitude.
In simple terms: Imagine two arrows pointing from the origin. Cosine similarity checks 'are they pointing the same way?' (angle), while Euclidean checks 'how far apart are their tips?' Two short arrows pointing together score high on cosine; two long arrows pointing apart score low, no matter their length. Example: vectors [1,0,0] and [2,0,0] point the same direction (cosine = 1.0) even though they're different lengths.
76+ more Embeddings & Vector DB questions inside
Create a free account to read the full question bank, learn every topic, and practise with an AI mock interview.
Unlock all questions — freeReady to practise Embeddings & Vector DB?
Unlock every topic free, then face an AI interviewer that asks follow-ups and grades your answers.