Chapter 13: Embeddings & Retrieval Strategies
Theoretical Foundations
At the heart of any Retrieval-Augmented Generation (RAG) system lies a fundamental transformation: converting unstructured, human-readable text into structured, machine-understandable numerical representations. This process is the domain of Embeddings.
To understand embeddings, we must first look back at Chapter 12: Structuring Data with Zod. In that chapter, we learned to enforce strict schemas on our data using Zod, ensuring that every piece of data adheres to a predictable shape. We validated user inputs, API responses, and internal state. Embeddings take this concept of "structure" to a profound new level. While Zod ensures our data types are correct (e.g., a string is a string, a number is a number), embeddings ensure our semantic meaning is quantifiable. We are moving from validating the syntax of a sentence to encoding its essence.
Imagine you are building a massive e-commerce platform. You have millions of product descriptions. A traditional keyword search (like a simple database LIKE query) is brittle. If a user searches for "comfortable running shoes," a keyword search might miss a product described as "lightweight sneakers perfect for jogging" because the exact words "comfortable" or "running" aren't present. This is a failure of semantic understanding.
Embeddings solve this by mapping words and sentences into a high-dimensional geometric space. In this space, words with similar meanings are located close to each other. The distance between two points in this space becomes a mathematical proxy for semantic similarity.
The Web Development Analogy: Embeddings as a Hash Map for Concepts
Think of a standard JavaScript Map or a hash table. It maps a key (e.g., a string "user_id") to a value (e.g., a number 12345). This is a deterministic, one-to-one mapping. It's fast and exact, but it has no concept of "closeness." The key "user_id" is not semantically similar to "customer_id"; they are either identical or completely different.
An embedding is like a conceptual hash map but with a crucial difference: it maps a concept (like a sentence) to a vector (a list of numbers), and the "hashing" function is designed so that similar concepts produce similar vectors.
- The Key: Instead of a simple string, the key is the input text (e.g., "The quick brown fox jumps over the lazy dog").
- The Value: Instead of a single ID, the value is a dense vector of floating-point numbers, for example:
[0.02, -0.5, 0.8, ..., 0.1]. This vector might have 1536 dimensions (common for OpenAI'stext-embedding-ada-002model).
The "magic" of the embedding model is that it has learned, from training on vast amounts of text, that the vector for "The quick brown fox..." will be mathematically very close (using a metric like cosine similarity) to the vector for "A fast, dark-colored fox leaps..." but will be far from the vector for "The stock market closed higher today."
This is the foundational principle: Semantic closeness is translated into geometric closeness. This allows us to use mathematical operations, which computers excel at, to perform semantic reasoning.
The "Why": The Limitations of Traditional Search and the Power of Semantic Space
Why go through this complex process of generating vectors? Why not just use keywords?
-
Synonymy and Polysemy: Natural language is messy.
- Synonymy: Different words with the same meaning (e.g., "car" and "automobile"). A keyword search for "car" will not find documents that only use the word "automobile." Embeddings, however, place these concepts near each other in the vector space.
- Polysemy: The same word has multiple meanings (e.g., "bank" as a financial institution vs. "bank" as a river's edge). Context is everything. An embedding model generates a vector for "I deposited money at the bank" that is vastly different from the vector for "I sat on the river bank," because the surrounding words provide context. A simple keyword search for "bank" would conflate these two unrelated concepts.
-
The Shift from Lexical to Semantic Search: Traditional search is lexical—it's about matching tokens. Semantic search, powered by embeddings, is about matching ideas. This is critical for RAG because the goal of retrieval is to find documents that are relevant to the user's intent, not just documents that share keywords.
-
Enabling Mathematical Retrieval: Once text is in vector form, the retrieval problem becomes a geometric one. We can calculate the distance between the user's query vector and all the document vectors in our database. The documents with the smallest distances are the most relevant. This is a task that vector databases are specifically designed to perform efficiently.
Under the Hood: The Mechanics of Embedding Generation
When we use a model like OpenAI's text-embedding-ada-002, we are not simply looking up words in a dictionary. We are using a deep neural network, specifically a Transformer-based model, that has been trained on a massive corpus of text.
- Tokenization: The input text is first broken down into smaller units called tokens (e.g., "Embeddings are powerful" might become
["Embed", "dings", " are", " powerful"]). - Model Inference: These tokens are fed into the embedding model. The model processes them through multiple layers of attention mechanisms, which allow it to weigh the importance of different words in the context of the entire sentence.
- Vector Output: The final layer of the model produces a fixed-size vector. For OpenAI's model, this is typically a 1536-dimensional vector. Each dimension is a floating-point number. These dimensions don't have an obvious human-readable meaning (e.g., dimension 42 isn't "the angerness score"), but collectively they capture the semantic essence of the input.
The model is a black box, but its output is a precise, reproducible numerical fingerprint of the input text's meaning.
The Vector Store: The Library of Concepts
Once we have generated embeddings for our documents (e.g., chunks of text from a knowledge base), we need a place to store them. This is the Vector Store.
A vector store is not a traditional relational database like PostgreSQL (without extensions) or a document store like MongoDB. It is a specialized database optimized for one primary task: finding the nearest neighbors to a given vector in a high-dimensional space.
Analogy: The Librarian's Filing System
Imagine a physical library. * Traditional Database: Books are filed alphabetically by title. To find all books about "quantum physics," you must scan every single book title. This is slow and inefficient. * Vector Store: The library has a magical filing system. Each book is placed on a shelf based on its conceptual content. All books about "quantum physics" are physically close to each other, and books about "cooking" are in a completely different section of the library. When you ask the librarian (the query) for "books about quantum mechanics," the librarian doesn't need to scan titles. They can instantly navigate to the "quantum physics" section and grab the books that are closest to that conceptual location.
This is what a vector store does. It organizes data not by an arbitrary identifier (like a primary key) but by its semantic position in the vector space. When we query it, we provide a vector (the embedding of our user's question), and it efficiently returns the closest matching vectors (and their associated text chunks) from its index.
Common vector stores include Pinecone, Milvus, Weaviate, and even PostgreSQL with the pgvector extension. In the JavaScript/TypeScript ecosystem, libraries like LangChain.js provide abstractions over these, allowing you to switch between them with minimal code changes.
The Retrieval Algorithm: K-Nearest Neighbors (KNN)
How does the vector store actually find the "closest" vectors? The core algorithm is K-Nearest Neighbors (KNN).
In the context of RAG, KNN is the process of:
1. Taking the user's query (e.g., "What are the side effects of Ibuprofen?").
2. Generating its embedding vector using the same model used for the documents.
3. Searching the vector store for the K document vectors that are mathematically closest to the query vector.
Distance Metrics: How is "Closeness" Measured?
"Closest" is defined by a distance metric. The two most common metrics used in semantic search are:
-
Cosine Similarity: This measures the cosine of the angle between two vectors. It's a measure of orientation, not magnitude. This is ideal for text embeddings because the direction of the vector (the semantic meaning) is more important than its length (which can be influenced by document length).
- Range: -1 to 1. A value of 1 means the vectors are identical in direction (perfect match). 0 means they are orthogonal (unrelated). -1 means they are opposite (e.g., "hot" vs. "cold").
- In Practice: We usually look for the highest cosine similarity score (closest to 1).
-
Euclidean Distance: This is the straight-line distance between two points in the vector space. It's the classic Pythagorean theorem. While intuitive, it can be sensitive to the magnitude of the vectors, which might not always correlate with semantic meaning.
The "K" in KNN:
The K parameter is a hyperparameter you control. It defines how many relevant documents you want to retrieve.
* Low K (e.g., K=1 or 2): You get very precise, highly relevant results. However, you might miss out on broader context.
* High K (e.g., K=10 or 20): You get more context, but you also risk including less relevant documents that could dilute the LLM's focus or introduce noise.
This trade-off is a critical part of designing a robust RAG pipeline.
Orchestrating the RAG Pipeline: A Visual Flow
The RAG pipeline is the complete sequence that binds these concepts together. It's a workflow that transforms raw data into context-aware AI responses.
Here is a visual representation of the data flow:
This diagram illustrates the two distinct phases of the RAG pipeline:
- Offline (Ingestion): The left side (clusters 0 and 1) is typically run once or periodically. We load our data, split it into manageable chunks, generate embeddings for each chunk, and store them in the vector database. This is the "building the library" phase.
- Online (Query-Time): The right side (cluster 2) happens in real-time when a user asks a question. We embed the query, search the vector store for the most relevant chunks (KNN), and then feed those chunks to the LLM as context in a carefully constructed prompt. This is the "asking the librarian" phase.
By grounding the LLM's response in these retrieved, semantically relevant chunks of text, we dramatically reduce the risk of hallucination and provide the model with information it wasn't originally trained on. This is the power of a well-constructed RAG pipeline.
Basic Code Example
This example demonstrates a minimal, self-contained Retrieval-Augmented Generation (RAG) pipeline using TypeScript. We will simulate a SaaS application that answers questions about a specific "Product Knowledge Base."
We will use: 1. OpenAI API: To generate text embeddings (vector representations) and to perform the final generation. 2. In-Memory Vector Store: Instead of a complex database like Pinecone, we will use a simple array to store vectors. This allows the code to run immediately without external infrastructure setup, while demonstrating the core logic of vector math.
Prerequisites:
* Node.js installed.
* An OpenAI API Key.
* Install dependencies: npm install openai
/**
* SIMPLE RAG PIPELINE: Product Knowledge Base Assistant
*
* Context: SaaS App - Internal Support Tool
* Goal: Answer user queries by retrieving relevant context from a stored knowledge base.
*
* Note: This is a "Hello World" example using an in-memory vector store.
* In production, you would use a dedicated vector database (e.g., Pinecone, pgvector).
*/
import OpenAI from 'openai';
// 1. CONFIGURATION
// -----------------------------------------------------------------------------
const OPENAI_API_KEY = process.env.OPENAI_API_KEY || 'sk-your-api-key-here';
const openai = new OpenAI({ apiKey: OPENAI_API_KEY });
// Mock database of documents (Product Knowledge Base)
const documents = [
{ id: 'doc_1', content: 'The SmartLight X1 uses a standard E26 base and consumes 10W.' },
{ id: 'doc_2', content: 'To reset the SmartLight X1, toggle the power switch 5 times rapidly.' },
{ id: 'doc_3', content: 'The warranty for the SmartLight X1 is 2 years for residential use.' },
{ id: 'doc_4', content: 'The SmartLight X1 is compatible with Alexa, Google Home, and Apple HomeKit.' },
];
// 2. VECTOR STORE SIMULATION
// -----------------------------------------------------------------------------
/**
* Represents a vector entry in our store.
* In a real DB, this would be a row in a table with a vector column.
*/
interface VectorEntry {
id: string;
content: string;
embedding: number[]; // The numerical vector representation
}
/**
* A simple in-memory vector store class.
* This simulates the behavior of a dedicated vector database.
*/
class InMemoryVectorStore {
private vectors: VectorEntry[] = [];
/**
* Adds a document and its embedding to the store.
* @param entry - The vector entry containing ID, content, and embedding.
*/
async add(entry: VectorEntry) {
this.vectors.push(entry);
}
/**
* Performs a similarity search (K-Nearest Neighbors).
* Calculates the Euclidean distance (simplified) between the query vector and stored vectors.
* @param queryEmbedding - The vector representing the user's question.
* @param k - Number of top results to return.
* @returns The top K most similar documents.
*/
async similaritySearch(queryEmbedding: number[], k: number = 2) {
// Calculate similarity scores
const scored = this.vectors.map((vec) => {
const similarity = cosineSimilarity(queryEmbedding, vec.embedding);
return { ...vec, similarity };
});
// Sort by similarity (highest first) and take top K
return scored.sort((a, b) => b.similarity - a.similarity).slice(0, k);
}
}
// 3. HELPER FUNCTIONS (MATH & EMBEDDINGS)
// -----------------------------------------------------------------------------
/**
* Calculates the Cosine Similarity between two vectors.
* This is the standard metric for comparing text embeddings.
* Range: -1 (completely opposite) to 1 (identical). For embeddings, usually 0 to 1.
*/
function cosineSimilarity(vecA: number[], vecB: number[]): number {
const dotProduct = vecA.reduce((acc, val, i) => acc + val * vecB[i], 0);
const magnitudeA = Math.sqrt(vecA.reduce((acc, val) => acc + val * val, 0));
const magnitudeB = Math.sqrt(vecB.reduce((acc, val) => acc + val * val, 0));
return dotProduct / (magnitudeA * magnitudeB);
}
/**
* Generates an embedding for a given text string using OpenAI.
* @param text - The text to embed.
* @returns A promise that resolves to the embedding array.
*/
async function getEmbedding(text: string): Promise<number[]> {
const response = await openai.embeddings.create({
model: 'text-embedding-ada-002',
input: text,
});
return response.data[0].embedding;
}
/**
* Generates a response using GPT-4 with the retrieved context.
* @param query - The user's question.
* @param context - The retrieved documents to ground the answer.
* @returns The generated answer.
*/
async function generateAnswer(query: string, context: string[]): Promise<string> {
const contextText = context.join('\n\n');
const prompt = `
You are a helpful assistant for a smart home product company.
Answer the user question based ONLY on the following context:
Context:
"${contextText}"
Question: "${query}"
Answer:
`;
const completion = await openai.chat.completions.create({
model: 'gpt-4',
messages: [{ role: 'user', content: prompt }],
max_tokens: 150,
});
return completion.choices[0].message.content || 'I could not find an answer.';
}
// 4. MAIN EXECUTION LOGIC
// -----------------------------------------------------------------------------
/**
* Orchestrates the RAG pipeline.
* 1. Embed the query.
* 2. Retrieve relevant documents from the vector store.
* 3. Generate an answer using the retrieved context.
*/
async function runRagPipeline(userQuery: string) {
console.log(`\n[1] Processing Query: "${userQuery}"`);
// Step A: Embed the User Query
// This converts natural language into a numerical vector.
const queryEmbedding = await getEmbedding(userQuery);
console.log(`[2] Query embedded. Vector dimension: ${queryEmbedding.length}`);
// Step B: Retrieve (K-Nearest Neighbors)
// We search the vector store for the most similar documents.
const vectorStore = new InMemoryVectorStore();
// First, we need to populate the store (in a real app, this is pre-computed)
console.log(`[3] Populating Vector Store...`);
for (const doc of documents) {
const embedding = await getEmbedding(doc.content);
await vectorStore.add({ id: doc.id, content: doc.content, embedding });
}
// Perform the search
const retrievedDocs = await vectorStore.similaritySearch(queryEmbedding, 2); // K=2
console.log(`[4] Retrieved ${retrievedDocs.length} relevant documents.`);
// Step C: Generate Answer
// Pass the raw text of retrieved docs to the LLM for synthesis.
const contextTexts = retrievedDocs.map(d => d.content);
const answer = await generateAnswer(userQuery, contextTexts);
console.log(`\n[5] FINAL ANSWER:\n${answer}`);
}
// 5. RUN THE APP
// -----------------------------------------------------------------------------
// Example usage
const userQuestion = "How do I fix my SmartLight if it's not responding?";
runRagPipeline(userQuestion).catch(console.error);
Detailed Line-by-Line Explanation
1. Configuration & Setup
import OpenAI: We import the official OpenAI Node.js SDK. This handles the HTTP requests to the OpenAI API.documentsarray: This acts as our "Data Source." In a SaaS environment, this would typically be a PostgreSQL database or a set of Markdown files in a repository. We store objects containing an ID and the raw text content.
2. Vector Store Simulation (InMemoryVectorStore)
VectorEntryInterface: Defines the shape of our data. Crucially, it includesembedding: number[]. This is the numerical representation of the text.similaritySearchMethod:- The Math: It maps over every stored vector and calculates the Cosine Similarity against the query vector.
- Cosine Similarity: This measures the cosine of the angle between two vectors. In high-dimensional space (like embeddings), this is more robust than Euclidean distance because it focuses on orientation (direction) rather than magnitude (length), which is better for text meaning.
- Sorting: It sorts the results descendingly (highest similarity score first) and slices the top
kresults. This is the K-Nearest Neighbors (KNN) algorithm in its simplest form.
3. Helper Functions
getEmbedding:- Calls
openai.embeddings.createwith the modeltext-embedding-ada-002. - This model converts text (e.g., "The warranty is 2 years") into a vector of 1536 dimensions (an array of 1536 floating-point numbers).
- Why this matters: The embedding captures semantic meaning. "Reset" and "fix" will have vectors that are mathematically close, even if the words are different.
- Calls
generateAnswer:- Constructs a System Prompt. This is critical for RAG. We explicitly instruct the LLM to rely only on the provided context.
- Context Injection: We join the retrieved documents (
contextTexts) and inject them into the prompt. This prevents the LLM from hallucinating answers based on its training data alone.
4. Main Execution (runRagPipeline)
- Embed Query: The user's string is converted to a vector.
- Populate Store: In this example, we embed the documents on-the-fly. In production, documents are embedded once during ingestion (indexing) and stored in the vector DB.
- Retrieve: The query vector is sent to the
similaritySearchmethod. The system finds the documents most semantically related to the query. - Synthesize: The retrieved documents and the original question are sent to the Chat Completions API. The LLM reads the context and formulates a final answer.
Visualizing the RAG Flow
The following diagram illustrates the data flow in our code.
Common Pitfalls in JavaScript/TypeScript RAG Implementations
When building RAG pipelines in a TypeScript environment (especially server-side or serverless), watch out for these specific issues:
1. Async/Await Loops in Vector Ingestion
* The Issue: When populating a vector store with thousands of documents, developers often use forEach or a simple for loop with await inside.
* Why it fails: Array.forEach does not wait for promises to resolve. It fires all requests simultaneously, which can crash your app by hitting API rate limits (e.g., OpenAI's 3 RPM/minute limit on free tiers) or exhausting memory.
* The Fix: Use for...of loops for sequential processing or Promise.allSettled with batching for concurrency control.
2. Vector Dimension Mismatch
* The Issue: Using different embedding models for ingestion and retrieval.
* Why it fails: If you embed documents with text-embedding-ada-002 (1536 dimensions) but query with text-embedding-3-small (or vice versa, depending on settings), the cosine similarity calculation will be meaningless. The vectors must have the exact same length.
* The Fix: Hardcode the model name in a constant and ensure it is used consistently across your ingestion and retrieval scripts.
3. Vercel/Serverless Timeouts * The Issue: Vercel functions (or AWS Lambda) have strict timeouts (e.g., 10s on Hobby plans). A RAG pipeline involves multiple network calls: embedding the query, querying the vector DB, and generating the final LLM response. * Why it fails: If the vector DB query is slow or the LLM generates tokens slowly, the serverless function will time out before returning a response. * The Fix: * Move the heavy lifting (ingestion) to background jobs. * For retrieval, ensure your vector database index is optimized (e.g., using HNSW indexes in pgvector). * Increase the timeout limit in your serverless configuration if possible.
4. Hallucinated JSON / Parsing Errors
* The Issue: When asking the LLM to return structured data (e.g., a JSON object containing the answer and citations), the model might return a string with Markdown formatting (json ...) or trailing commas.
* Why it fails: JSON.parse() is strict. If the LLM adds conversational text ("Here is your JSON:"), parsing fails.
* The Fix: Use Zod (as covered in previous chapters) to validate the output. Never trust the LLM output directly without schema validation.
// Example Zod usage for RAG output
import { z } from 'zod';
const RagResponseSchema = z.object({
answer: z.string(),
sources: z.array(z.string())
});
// Parse LLM output here...
5. Ignoring Similarity Thresholds * The Issue: The KNN algorithm always returns K results, even if they are irrelevant. * Why it fails: If a user asks about "Warranty" and the vector store returns documents about "Setup" because the similarity score is 0.4 (low), and you feed that irrelevant context to the LLM, the model might try to force a connection, leading to a wrong answer. * The Fix: Implement a similarity threshold. If the top result's cosine similarity is below a certain value (e.g., 0.7), return a "I don't know" answer rather than forcing a generation.
The chapter continues with advanced code, exercises and solutions with analysis, you can find them on the ebook on Leanpub.com or Amazon
Loading knowledge check...
Code License: All code examples are released under the MIT License. Github repo.
Content Copyright: Copyright © 2026 Edgar Milvus | Privacy & Cookie Policy. All rights reserved.
All textual explanations, original diagrams, and illustrations are the intellectual property of the author. To support the maintenance of this site via AdSense, please read this content exclusively online. Copying, redistribution, or reproduction is strictly prohibited.