RAG in JavaScript, from embeddings to answers
By the StudyVisually teamPublished
RAG (retrieval-augmented generation) means searching your own documents first, then giving the best matches to a language model and telling it to answer only from them. In JavaScript it takes two jobs. Indexing runs once: split your documents into chunks, turn each chunk into an embedding, and store both in a database such as Postgres with pgvector. Querying runs for every question: embed the question, fetch the closest chunks, and send them to the model with the question. No model training is involved, and updating the answers means re-indexing a document.
Why not just ask the model?
A model only knows what was in its training data. It has never seen your company's runbooks, your course notes or last week's policy change. When it doesn't know, it often writes a confident guess anyway. RAG fixes both problems: the model gets your text, and an instruction to stay inside it (RAG lesson).
What is an embedding?
An embedding is a list of numbers that captures what a piece of text means. Texts with similar meanings get lists that point in similar directions, so "how do I roll back a deploy?" lands near "revert steps" even though they share no words.
const response = await client.embeddings.create({ model: "text-embedding-3-small", input: "How do I revert a deploy?",});const vector = response.data[0].embedding; // 1536 numbersOpenAI's guide says text-embedding-3-small returns 1,536 numbers by default and text-embedding-3-large returns 3,072 (embeddings guide). Store the vector next to the text it came from.
How do I compare two embeddings?
With cosine similarity: 1 means the same direction (same meaning), 0 means unrelated. OpenAI recommends cosine similarity for its embeddings and notes they are "normalized to length 1", so a plain dot product gives the same ranking (embeddings guide).
function cosineSimilarity(first, second) { const dot = first.reduce((sum, value, index) => sum + value * second[index], 0); return dot / (Math.hypot(...first) * Math.hypot(...second));}You rarely write this yourself. A vector database computes it for you, fast, over millions of rows (cosine similarity lesson).
Step 1: index your documents
- Split each document into chunks. The chunk size lesson suggests starting at 300 to 800 tokens with a little overlap, split on headings and paragraphs. Chunks that are too small lose context; chunks that are too big bury the one sentence you need.
- Embed each chunk.
- Store the chunk text, its vector, and where it came from (file, section, date).
for (const chunk of splitIntoChunks(documentText)) { const response = await client.embeddings.create({ model: "text-embedding-3-small", input: chunk }); await database.query("INSERT INTO docs (content, embedding) VALUES ($1, $2)", [ chunk, JSON.stringify(response.data[0].embedding), ]);}With pgvector, embedding is a vector(1536) column. Re-run this for a document when it changes.
Step 2: answer a question
Embed the question the same way, ask the database for the nearest chunks, and build the prompt.
const { rows } = await database.query("SELECT content FROM docs ORDER BY embedding <=> $1 LIMIT 4", [ JSON.stringify(questionVector),]);const context = rows.map((row) => row.content).join("\n---\n"); const answer = await client.chat.completions.create({ model, messages: [ { role: "system", content: "Answer only from the context. If the answer isn't there, say you don't know." }, { role: "user", content: `Context:\n${context}\n\nQuestion: ${question}` }, ],});In pgvector, <=> is cosine distance, so smaller means closer. LIMIT 4 is your top K: how many chunks the model reads. More chunks cost more and add noise.
The system message does a lot of work. "Answer only from the context, otherwise say you don't know" is a big part of how RAG cuts down made-up answers (grounding lesson).
My RAG gives wrong answers. What do I check?
Check retrieval before you blame the model. Print the chunks it was given for a bad answer:
| Symptom | Likely cause | Fix |
|---|---|---|
| The right chunk isn't in the results | Chunks too big or split mid-thought | Re-chunk on headings; add overlap |
| Exact names or error codes are missed | Meaning search is weak on exact strings | Add keyword search alongside (hybrid search) |
| The right chunk is there but ranked low | First-pass ranking is rough | Fetch 20, re-rank, keep the best 4 |
| The answer ignores the chunks | Weak instructions | Tighten the system message; ask it to quote |
Then build a small eval set: twenty real questions with known answers. Score every change against it instead of trying one question by eye.
How does RAG fit into an agent?
Turn the query step into a tool, searchDocs(query). The agent then decides when to search and can search again with a better query if the first results miss. This is called agentic RAG, and it is where tool calling and RAG meet (agentic RAG lesson).
Practise it
The embeddings and RAG part has 14 cards, from embeddings to re-ranking. The Step by step mode in the app plays the whole RAG pipeline step by step, and the interview questions include "your RAG gives wrong answers, how do you debug it?", which is worth being able to answer in under a minute.
Read it? Now test yourself on it.