rag-from-scratch
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
| Decision | Choice Made | Alternatives | Why This Approach |
|---|---|---|---|
| Class taxonomy | LangChain.js naming verbatim (Document, BaseLoader.load(), embedQuery/embedDocuments) | Custom names; functional API | Lower cognitive cost when learners graduate to LangChain proper. |
| Local vs cloud LLM | node-llama-cpp + GGUF default | OpenAI; HuggingFace Inference API | Runs offline, no API keys; matches "no black boxes" promise. OpenAI is supported optionally for query rewriting. |
| Vector DB | embedded-vector-db (HNSW, in-process) | Qdrant, Chroma, pgvector (network) | Local-first; zero infrastructure; sub-millisecond search at 10K vectors. |
| Lesson granularity | 15 numbered lessons with sub-numbering for complex topics | Flat 1..N; tagged by topic | Sub-numbering signals "this concept needs multiple passes" without dirtying the top-level. |
| Examples vs library | Examples re-implement what they teach; src/ crystallises after | Single shared scaffold; examples import everything | Cognitive locality — a reader must not have to open src/ to understand a lesson. |
| Module loading | ESM throughout ("type":"module") | CommonJS; bundled UMD | node example.js works without any build step. Aligns with promise of transparency. |
| Async setup | Static factory LlamaCpp.initialize() | Two-step new + await init() | Constructors can’t await; factory keeps init atomic so half-built instances never escape. |
| Embedding cache | Optional, side-channel, in-memory + JSON disk | Mandatory cache; SQLite | Caches 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/
Curriculum — examples/
CLI Infrastructure — helpers/
Navigation Guide
| Level | What You'll Find | When to Use |
|---|---|---|
| L0 — Project Hub (this page) | Philosophy, design decisions, module map, end-to-end flows | Getting oriented; understanding the big picture |
| L1 — Module Overview | Module rationale, design decisions, narrated walkthrough, performance profile | Understanding what a module does and why |
| L2 — Sub-Module Deep Dive | Annotated code walkthroughs, classes, edge cases, configuration knobs | Implementing, debugging, or extending |
| Search | Full-text search across all wiki pages | Finding 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 emptysrc/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.