rag-from-scratch

Build Retrieval-Augmented Generation (RAG) yourself — no black boxes.

A dependency-light, local-first RAG toolkit that doubles as a JavaScript library and a sequenced tutorial. Each lesson is runnable end-to-end; each module mirrors LangChain.js shapes without LangChain's weight.

JavaScript ESM Local LLM · node-llama-cpp 15 lessons Educational + Library

🎯 Project Philosophy

When you first open rag-from-scratch, the most striking design choice is what is missing: no Docker, no Postgres, no API keys, no Python. Everything runs in Node 18+, on a laptop, with files you can cat. The author's wager is that RAG — a topic that sounds intimidating in the LangChain era — becomes obvious if you are forced to write the keyword-scorer, the splitter, the embedding cache, and the rank-fusion equation yourself. The repo is built around this wager.

Two halves split the same idea. examples/ is the curriculum: fifteen numbered lessons (00–06), each a self-contained example.js plus a CONCEPT.md (the why) and a CODE.md (the line-by-line walk). src/ is the library — the layers that have been promoted out of the examples once the abstraction stabilised, named to mirror LangChain.js so that learners can transfer concepts later. The current src/ is honestly partial: chains/, retrievers/, vector-stores/, and prompts/ hold scaffolded index.js stubs because those layers are still being taught in lessons; they will graduate next.

The third design priority is cognitive locality over DRY. Lesson 03 re-implements its own TextSplitter even though src/text-splitters/ exists. Why? Because a reader of lesson 03 must be able to understand it without opening src/. A tab-jumping codebase fails the educational mission. The library exists for downstream consumers; the lessons exist for human eyes.

🏗️ Design Decisions

DecisionChoice MadeAlternativesWhy This Approach
Class taxonomyLangChain.js naming verbatim (Document, BaseLoader.load(), embedQuery/embedDocuments)Custom names; functional APILower cognitive cost when learners graduate to LangChain proper.
Local vs cloud LLMnode-llama-cpp + GGUF defaultOpenAI; HuggingFace Inference APIRuns offline, no API keys; matches "no black boxes" promise. OpenAI is supported optionally for query rewriting.
Vector DBembedded-vector-db (HNSW, in-process)Qdrant, Chroma, pgvector (network)Local-first; zero infrastructure; sub-millisecond search at 10K vectors.
Lesson granularity15 numbered lessons with sub-numbering for complex topicsFlat 1..N; tagged by topicSub-numbering signals "this concept needs multiple passes" without dirtying the top-level.
Examples vs libraryExamples re-implement what they teach; src/ crystallises afterSingle shared scaffold; examples import everythingCognitive locality — a reader must not have to open src/ to understand a lesson.
Module loadingESM throughout ("type":"module")CommonJS; bundled UMDnode example.js works without any build step. Aligns with promise of transparency.
Async setupStatic factory LlamaCpp.initialize()Two-step new + await init()Constructors can’t await; factory keeps init atomic so half-built instances never escape.
Embedding cacheOptional, side-channel, in-memory + JSON diskMandatory cache; SQLiteCaches must never break the primary path; JSON dumps are inspectable by hand.

System Architecture

flowchart TD
  subgraph Library["src/ — the library"]
    Doc["Document
(universal payload)"] Loaders["loaders/
BaseLoader, PDFLoader"] Splitters["text-splitters/
Base/Character/Recursive/Token"] Embeds["embeddings/
EmbeddingModel + EmbeddingCache"] LLMs["llms/
BaseLLM + LlamaCpp"] Chat["chat-models/
ChatLlamaCpp"] Stub1["chains/ retrievers/
vector-stores/ prompts/
(scaffolded stubs)"] end subgraph Curriculum["examples/ — the lessons"] Ex0["00 How RAG Works
(no embeddings)"] Ex1["01 LLM basics"] Ex2["02 Data Loading"] Ex3["03 Splitting"] Ex4["04 Embeddings"] Ex5["05 Vector Store
(3 sub-lessons)"] Ex6["06 Retrieval Strategies
(5 sub-lessons)"] end subgraph Helpers["helpers/ — CLI chrome"] Out["OutputHelper
(chalk + ora)"] Llp["LlamaPrompter
(used by 06)"] Op["OpenAIClient"] end Loaders -->|emits| Doc Splitters -->|emits| Doc Doc --> Embeds Embeds -->|number array 384d| Stub1 LLMs --> Chat Ex2 -->|imports| Loaders Ex3 -->|imports| Doc Ex4 -->|reimplements then uses| Embeds Ex5 -->|imports + uses HNSW| Doc Ex6 -->|uses| LLMs Ex6 -->|uses| Llp Ex0 -->|standalone| Curriculum Curriculum -->|formatted via| Out

Library — src/

src/

The reusable RAG library — LangChain-shaped, dependency-light

10 sub-modules: embeddings, text-splitters, loaders, llms, chat-models, utils are filled in; chains, retrievers, vector-stores, prompts are scaffolded for upcoming lessons.

~1,857 LOC10 sub-dirs40 .js files

Curriculum — examples/

examples/

15 numbered tutorial lessons (00–06)

Each lesson is example.js + CONCEPT.md + CODE.md. Self-contained — runnable without reading any other lesson.

~6,954 LOC15 lessons3 artefact types

CLI Infrastructure — helpers/

helpers/

chalk/ora output + OpenAI/llama prompter wrappers

3 files providing terminal UX (OutputHelper) and LLM access (OpenAIClient, LlamaPrompter). Used by every example for consistent CLI presentation.

152 LOC3 fileschalk + ora + openai
LevelWhat You'll FindWhen to Use
L0 — Project Hub (this page)Philosophy, design decisions, module map, end-to-end flowsGetting oriented; understanding the big picture
L1 — Module OverviewModule rationale, design decisions, narrated walkthrough, performance profileUnderstanding what a module does and why
L2 — Sub-Module Deep DiveAnnotated code walkthroughs, classes, edge cases, configuration knobsImplementing, debugging, or extending
SearchFull-text search across all wiki pagesFinding a specific term or class fast

🔗 Key Data Flows

Flow A — Document loaded, split, and embedded

The classic preprocessing pipeline. Annotations explain WHY each stage exists.

sequenceDiagram
    participant U as User
    participant PDF as PDFLoader
    participant Split as RecursiveCharacterTextSplitter
    participant Cache as EmbeddingCache
    participant EM as EmbeddingModel
    participant Llama as node-llama-cpp
    U->>PDF: new PDFLoader(url).load()
    Note over PDF: WHY: pdf-parse normalises
whitespace + page boundaries PDF-->>U: Document[] with metadata.page U->>Split: splitDocuments(docs) Note over Split: WHY: chunks must fit context window
and respect paragraph/sentence boundaries Split-->>U: Document[] (chunked, with overlap) U->>EM: embedDocuments(texts) EM->>Cache: get(text) alt Cache hit Cache-->>EM: vector Note over Cache,EM: WHY: embedding is the slow path
~10-50ms per call else Cache miss EM->>Llama: ctx.getEmbeddingFor(text) Llama-->>EM: Float32Array[384] EM->>Cache: set(text, vector) end EM-->>U: number[384][]

Flow B — Advanced retrieval with multi-query and RRF

Lesson 06/04 wires together LLM-driven query expansion, parallel retrieval, and rank fusion.

sequenceDiagram
    participant U as User Query
    participant LLM as LlamaPrompter
    participant Retr as retrieveParallel()
    participant DB as embedded-vector-db
    participant Fuse as rrfFuse(k=60)
    participant Gen as ChatLlamaCpp
    U->>LLM: "rewrite as 3 paraphrases"
    Note over LLM: WHY: single query misses
vocabulary mismatches LLM-->>U: [orig, para1, para2] U->>Retr: retrieveParallel(queries) par embed + search per query Retr->>DB: search(NS, vec1, k=5) Retr->>DB: search(NS, vec2, k=5) Retr->>DB: search(NS, vec3, k=5) end DB-->>Retr: 3 ranked lists Retr->>Fuse: rrfFuse(lists) Note over Fuse: WHY: similarity scores from
different queries aren't comparable Fuse-->>U: top-N fused U->>Gen: prompt + retrieved context Gen-->>U: grounded answer

Flow C — The reader's path through the curriculum

Each lesson teaches one concept then layers complexity. The promotion frontier from examples/ to src/ is the dotted edge.

flowchart LR
  L0["00 How RAG works
(keyword)"] --> L1["01 LLM basics"] L1 --> L2["02 Data loading"] L2 --> L3["03 Splitting"] L3 --> L4["04 Embeddings"] L4 --> L5a["05/01 In-memory store"] L5a --> L5b["05/02 NN search"] L5b --> L5c["05/03 Metadata filtering"] L5c --> L6a["06/01 Basic retrieval"] L6a --> L6b["06/02 Query preprocess"] L6b --> L6c["06/03 Hybrid search"] L6c --> L6d["06/04 Multi-query"] L6d --> L6e["06/05 Query rewriting"] L6e -.->|promotion frontier| Lib["src/retrievers/
(scaffold)"]

How src/ & examples/ Connect

The boundary is one-directional and deliberately thin. examples/ imports from src/ only the abstractions that have already been promoted (Document, PDFLoader). Everything else — splitters in lesson 03, vector store wiring in lesson 05 — is re-implemented inline. src/ never imports from examples/. This is the cognitive-locality contract: lessons stay self-readable; the library is downstream of the curriculum, not the reverse.

Key insight: Examples teach. src/ crystallises. Helpers ornament. Reading the empty src/ stubs (chains/, retrievers/, vector-stores/, prompts/) tells you exactly which lessons are next on the author's roadmap.

Directory Map

Project source tree
rag-from-scratch/
├── src/                              ← The library half
│   ├── index.js                      ← Re-exports every sub-module
│   ├── embeddings/                   ← Mature: text -> vectors with cache
│   ├── text-splitters/               ← Mature: chunk + overlap strategies
│   ├── loaders/                      ← Partial: only PDFLoader filled
│   ├── llms/                         ← Mature: BaseLLM + LlamaCpp
│   ├── chat-models/                  ← Minimal: ChatLlamaCpp (32 LOC)
│   ├── utils/                        ← Document + llama_cpp factories
│   ├── chains/                       ← Stub (interface promised, empty files)
│   ├── retrievers/                   ← Stub
│   ├── vector-stores/                ← Stub
│   └── prompts/                      ← Stub (templates/ has 3 prompt strings)
├── examples/                         ← The curriculum (15 lessons)
│   ├── 00_how_rag_works/             ← Pure JS, no embeddings
│   ├── 01_intro_to_llms/             ← node-llama-cpp basics + wrapper
│   ├── 02_data_loading/              ← PDF parsing
│   ├── 03_text_splitting_and_chunking/
│   ├── 04_intro_to_embeddings/       ← similarity then generation (2 sub-lessons)
│   ├── 05_building_vector_store/     ← in-memory, NN search, metadata (3 sub-lessons)
│   └── 06_retrieval_strategies/      ← basic, query-prep, hybrid, multi-query, rewriting (5 sub-lessons)
├── helpers/                          ← CLI chrome + LLM wrappers (3 files)
├── models/                           ← .gitkeep; users put GGUFs here
├── images/                           ← README diagrams
├── package.json                      ← "type":"module", 8 runtime deps
├── README.md DOWNLOAD.md             ← Setup + concept overview
└── .env_example                      ← OPENAI_API_KEY (optional)

Build & Tech Stack

Language

JavaScript (ESM only). "type":"module" in package.json. Node 18+. No transpiler.

Build System

None. npm install then node examples/…/example.js. The "no black boxes" promise extends to the build pipeline.

Runtime Deps

node-llama-cpp (local LLM), embedded-vector-db (HNSW), pdf-parse, openai, chalk, ora, boxen, dotenv.

Models

bge-small-en-v1.5.Q8_0.gguf for embeddings (384-d). Qwen3-1.7B.Q8_0 or similar for generation. See DOWNLOAD.md.