src/ — The Library
Ten sub-modules forming a layered RAG library. Six are filled in (embeddings, text-splitters, loaders, llms, chat-models, utils); four are scaffolded stubs awaiting upcoming lessons.
10 sub-modules ~1,857 LOC LangChain-shaped Partial impl
📚 What is this & why does it exist?
src/ is the library half of rag-from-scratch. It is the code a downstream consumer would npm install and import from. It exists as a separate unit (rather than just publishing the examples/) so the project can train two muscles at once: (1) explain a concept end-to-end in a single self-contained example.js, then (2) extract the stable abstractions into a layered library that mirrors LangChain.js. Without src/, every example would re-implement Document, the splitter base, and the embedding cache — making the educational arc collapse into a flat pile of scripts.
💡 Real-world analogy
Imagine a cookbook author who teaches by giving you complete recipes (examples/) and, in a separate section at the back, the formal techniques those recipes rely on (src/). Each recipe is self-contained — you don't need the techniques chapter to make the dish — but if you want to build new recipes, the techniques chapter has the named primitives ("a roux", "a beurre blanc") with proper definitions. The techniques chapter is honest about what it covers: some sections are full chapters, others are placeholder pages because the author hasn't yet found the exemplary recipe to derive the technique from.
🎯 Why This Module Exists
The src/ tree is a deliberate exercise in API archaeology — copying LangChain.js's class shape (Document, BaseLoader.load(), BaseTextSplitter.splitText(), Embeddings.embedQuery(), BaseLLM.invoke/stream) but stripping every abstraction the educational arc doesn't yet need. A reader who graduates from this codebase can open LangChain's docs and recognise every primitive.
The current src/ is deliberately partial: only the layers the first six lessons depend on are filled in (embeddings/, text-splitters/, loaders/, llms/, utils/). The empty chains/, retrievers/, vector-stores/, and prompts/ are scaffolded with named-export index.js files — they hold a place in the import graph for future lessons. Without this module, every example would duplicate ~50 lines of Document, splitter base, and cache plumbing; the curriculum would lose its sense of progression.
🏗️ Design Decisions
| Decision | Choice Made | Alternatives | Why |
|---|---|---|---|
| Class taxonomy | LangChain.js naming verbatim | Custom names; functional API | Lower cognitive cost when learners graduate to LangChain proper. |
| Async setup | Static factory LlamaCpp.initialize() returning the instance | new LlamaCpp(); await x.init(); | Atomic init prevents "half-constructed" instances escaping; constructors can’t await. |
| Cache key | SHA-256 hash of the text alone | (text + modelPath); raw text key | Single-model usage assumed; fixed-length keys simplify on-disk JSON. Hash hides text in dumps unless useHash:false. |
| Cache eviction | LRU by lastAccessed via linear scan | LFU; FIFO; doubly-linked-list LRU | O(n) eviction is rare on the hot path; readability wins over O(1) bookkeeping for an educational repo. |
| Splitter overlap | mergeSplits shifts the front of current[] while length > chunkOverlap | Re-slice from end of previous chunk | Char-accurate overlap regardless of separator; the alternative tangles with separator stripping. |
| Token estimation | chars / 4 heuristic in TokenTextSplitter | Bundle tiktoken; ship a wasm tokenizer | Zero-dependency. Acceptable error band — real BPE tokens average ~4 chars in English. |
| ESM throughout | "type":"module" + relative ./Foo.js imports | CommonJS; bundled UMD | "No black boxes" promise: node example.js works without any build step. |
Architecture
classDiagram
class Document {
+pageContent: string
+metadata: object
+id?: string
+toJSON()
+fromJSON()
}
class BaseLoader {
<<abstract>>
+load() Document[]
+loadAndSplit(splitter)
+loadLazy()
}
class PDFLoader {
+source: string|Buffer
+splitPages: boolean
+load()
}
class BaseTextSplitter {
+chunkSize: number
+chunkOverlap: number
+lengthFunction(t)
+splitText()*
+splitDocuments(docs)
+mergeSplits(splits, sep)
}
class CharacterTextSplitter {
+separator: string
}
class RecursiveCharacterTextSplitter {
+separators: string[]
+splitText(text)
}
class TokenTextSplitter {
+encodingName: string
}
class EmbeddingModel {
+modelPath: string
+dimensions: 384
+cache: EmbeddingCache
+initialize()
+embedQuery(text)
+embedDocuments(texts)
+similarity(a, b)
}
class EmbeddingCache {
+maxSize: 10000
+get(text)
+set(text, vec)
+saveToDisk()
+_evictLRU()
}
class BaseLLM {
<<abstract>>
+invoke(prompt)*
+stream(prompt)*
+batch(prompts)
}
class LlamaCpp {
+static initialize(inputs)
+invoke(prompt, opts)
+stream(prompt, opts)
+dispose()
}
class ChatLlamaCpp {
+invoke(messages)
+_formatMessagesToPrompt(msgs)
}
BaseLoader <|-- PDFLoader
BaseTextSplitter <|-- CharacterTextSplitter
BaseTextSplitter <|-- RecursiveCharacterTextSplitter
BaseTextSplitter <|-- TokenTextSplitter
BaseLLM <|-- LlamaCpp
LlamaCpp <|-- ChatLlamaCpp
EmbeddingModel *-- EmbeddingCache
PDFLoader ..> Document : emits
BaseTextSplitter ..> Document : emits
EmbeddingModel ..> Document : reads
Data Flow
sequenceDiagram
participant U as User code
participant L as PDFLoader
participant S as RecursiveCharSplitter
participant E as EmbeddingModel
participant C as EmbeddingCache
participant Lc as node-llama-cpp
U->>L: new PDFLoader(url).load()
L->>L: pdf-parse + cleanText
L-->>U: Document[]
U->>S: splitDocuments(docs)
S->>S: choose separator from priority list
S->>S: mergeSplits with overlap
S-->>U: Document[] chunked
U->>E: embedDocuments(texts)
loop batch of 32
E->>C: get(text) [SHA-256 key]
alt hit
C-->>E: vector
else miss
E->>Lc: ctx.getEmbeddingFor(text)
Lc-->>E: Float32Array[384]
E->>C: set(text, vector)
end
end
E-->>U: number[][]
Note over U,Lc: User now stores vectors in their
preferred vector DB. src/vector-stores/
is the future home for wrappers.
🚶 Step-by-Step Narrated Walkthrough
Follow a single PDF page from disk to a vector ready for retrieval:
- User constructs
new PDFLoader(url, {splitPages:true})— constructor storessourceandsplitPages; no async work yet (constructors can'tawait). - User calls
await loader.load()— this is where work begins. The loader instantiatesPDFParse({url})from thepdf-parsepackage. - Loader fetches PDF info via
parser.getInfo()— gives uspagescount,info.version, metadata. Cheap call (~10ms remote, <1ms local). - Per-page extraction loop calls
parser.getText({partial:[i+1]}). Each page text passes throughcleanText()— collapse whitespace, cap consecutive newlines at 2. constructDocumentbuilds the metadata bag:{source, pdf:{version,info,metadata,totalPages}, page:N, loc:{pageNumber:N}}. Returns aDocument.- User passes Documents to splitter:
await new RecursiveCharacterTextSplitter({chunkSize:1000, chunkOverlap:200}).splitDocuments(docs). Splitter callscreateDocuments(texts, metadatas)internally. - Splitter picks the most coarse separator that exists in the text from
['\n\n','\n','. ',' ','']. For a typical PDF page, double-newline appears, so paragraphs are the primary split unit. - Each split is sized via
lengthFunction(defaultt => t.length). Pieces ≤chunkSizestack intotemp[]; oversize pieces recurse into the next finer separator. mergeSplitshandles overlap: as it accumulates pieces into a chunk, when the chunk hitschunkSizeit flushes, then shifts pieces off the front untillength ≤ chunkOverlap. The remaining pieces become the prefix of the next chunk.- Chunked Documents inherit metadata plus
chunk:jandtotalChunks:N. The PDF's page number is preserved — you can later cite the source. - User constructs
new EmbeddingModel({modelPath:'./models/bge-small.gguf'})— sets defaults (dim=384, batchSize=32, cache=true). Model is NOT loaded yet. await embeddings.initialize()— idempotent. CallsgetLlama() → loadModel() → createEmbeddingContext(). ~1.5s cold start, dominates the pipeline.await embeddings.embedDocuments(texts)— batches into chunks of 32, runs each batch asPromise.all(batch.map(per-text)). Per text: cache lookup (SHA-256 hash) — if hit, return immediately; if miss, callctx.getEmbeddingFor(text)and write the result back.- Vectors return as
number[][]. The user is now responsible for storing them —src/vector-stores/is empty, so this lesson explicitly delegates toembedded-vector-dbin the calling example.
Key Components
| Component | File | Role & Why It Exists |
|---|---|---|
Document | src/utils/Document.js | The universal payload — {pageContent, metadata, id?}. Everything that flows between modules is a Document. Stable interface; changes here ripple to ~70 files. |
BaseLoader | src/loaders/BaseLoader.js | Abstract base for every loader. Defines load(), loadAndSplit(splitter), loadLazy(). Why abstract? Mirrors LangChain shape and signals "subclass me to add file-format support". |
PDFLoader | src/loaders/PDFLoader.js | Only concrete loader. Wraps pdf-parse. splitPages toggle yields one Document per page or one merged Document. |
BaseTextSplitter | src/text-splitters/BaseTextSplitter.js | Holds chunkSize, chunkOverlap, lengthFunction, plus splitDocuments, createDocuments, mergeSplits. Subclasses only override splitText(). |
RecursiveCharacterTextSplitter | src/text-splitters/RecursiveCharacterTextSplitter.js | The recommended splitter. Walks ['\n\n','\n','. ',' ',''] and recurses into oversize pieces with finer separators. LangChain-standard. |
CharacterTextSplitter | src/text-splitters/CharacterTextSplitter.js | Single-separator splitter. Useful when text has predictable structure (CSV, log lines). |
TokenTextSplitter | src/text-splitters/TokenTextSplitter.js | Approximates token count as chars/4. Why approximate? Avoids tiktoken dependency; lengthFunction seam lets a real tokenizer drop in later. |
EmbeddingModel | src/embeddings/EmbeddingModel.js | LangChain-shaped embedQuery/embedDocuments. Lazy initialize(). Optional injected cache. Built-in cosine similarity helper. |
EmbeddingCache | src/embeddings/EmbeddingCache.js | Bounded in-memory cache (LRU) plus JSON disk persistence. Stats include hitRate, evictions. Designed as a side-channel — never throws. |
BaseLLM | src/llms/BaseLLM.js | Abstract: invoke / stream / batch / dispose / getModelType. Throws on direct instantiation. |
LlamaCpp | src/llms/LlamaCpp.js | Concrete LLM via node-llama-cpp. Static factory initialize. Optional GBNF grammar. Streaming is currently a placeholder. |
ChatLlamaCpp | src/chat-models/ChatLlamaCpp.js | Subclass adding _formatMessagesToPrompt for [{role,content}] arrays. Minimal — proves the inheritance shape works. |
llama_cpp.js | src/utils/llama_cpp.js | Factory functions createLlamaModel/Context/Session/Grammar. Why split out? Lets LlamaCpp.initialize read like a recipe rather than embedding 5 awaits. |
⚡ Algorithm Spotlight
Three algorithms drive the substantive sub-modules:
| Algorithm | Purpose | Why This One | Complexity |
|---|---|---|---|
Recursive separator-aware splitting (RecursiveCharacterTextSplitter.splitText) | Chunk text while preserving paragraph > sentence > word boundaries. | Hard char-window destroys boundaries; sentence tokenizers miss code/markup; NLP sentencizers add deps. Recursive separators degrade gracefully. | O(N · logₙ N), N=text length |
LRU eviction by linear scan (EmbeddingCache._evictLRU) | Drop least-recently-used entry when cache hits maxSize. | O(n) is fine because evictions are rare on the hot path. Doubly-linked-list LRU adds 30+ lines of bookkeeping. | O(n) per eviction |
Manual cosine similarity (EmbeddingModel._cosineSimilarity) | Score similarity between two embedding vectors. | Manual loop is textbook — reader sees (a·b)/(‖a‖·‖b‖). SIMD via Float32Array would be faster but illegible. | O(d), d=384 |
Deep-Dive Highlights
Core sub-modules extracted into dedicated pages.
embeddings Mature
EmbeddingModel + EmbeddingCache — SHA-256 keyed LRU, batch parallelism, cosine similarity helper.
text-splitters Mature
BaseTextSplitter + Character/Recursive/Token. Recursive separator algorithm walked step by step.
loaders Partial
PDFLoader (pdf-parse wrapper) plus BaseLoader abstract. DirectoryLoader, TextLoader are stubs.
llms Mature
BaseLLM + LlamaCpp. Static factory pattern, GBNF support, fake-stream placeholder.
⚠ What Could Go Wrong
| Failure | How Detected | Recovery | Cascading Effects |
|---|---|---|---|
| Missing GGUF model file | node-llama-cpp throws on loadModel() | Surface to user with clear path; user follows DOWNLOAD.md | Embedder + LLM both fail to initialize. Examples can't run end-to-end. |
Invalid chunkOverlap ≥ chunkSize | Constructor throws synchronously | Fail fast at construction, before any work | None — never reaches splitting. |
Method called before initialize() | _ensureInitialized() throws | Re-call sequence: new + initialize() + embed | Prevents NPEs deep in llama.cpp bindings. |
| Cache disk corruption | JSON.parse throws on loadFromDisk | Caller catches; cache stays empty (cold start) | Performance hit on first batch; correctness intact. |
Cache full at maxSize | _evictLRU evicts oldest | Stats track evictions count for tuning | Slight slowdown if working-set > cache size. |
📊 Performance Profile
Fast paths. Cache hits in EmbeddingModel.embedQuery are O(1) Map lookup plus a SHA-256 (~5µs for short text). Batch embedding parallelises within a batch via Promise.all — wall-clock is bound by the slowest item, not the sum. Splitter mergeSplits is a single O(N) pass over the input.
Slow paths. Cold model load (LlamaCpp.initialize) is ~1–3 s for a 1B-parameter quantised model on a CPU laptop. Embedding fresh text routes through llama.cpp's getEmbeddingFor() — ~10–50 ms per call depending on model size and CPU. Chunked PDFs of typical academic-paper length (10–30 pages) take ~5–15 s end-to-end on the first run, sub-second on subsequent runs (cache).
Memory. EmbeddingCache is bounded by maxSize (default 10,000). At 384 dims × 4 bytes = ~1.5 KB per entry → ~15 MB at full capacity. Disk dumps write JSON (verbose but trivially loadable). Tradeoff: no Protobuf / no MessagePack — sticking to JSON keeps the cache human-inspectable, which is what an educational repo wants.
Directory Map
src/ source tree
src/
├── index.js ← Re-exports every sub-module
├── embeddings/
│ ├── EmbeddingModel.js (320 LOC) ← LangChain-shaped wrapper
│ ├── EmbeddingCache.js (425 LOC) ← LRU + JSON persistence
│ └── index.js
├── text-splitters/
│ ├── BaseTextSplitter.js (99 LOC)
│ ├── CharacterTextSplitter.js (18 LOC)
│ ├── RecursiveCharacterTextSplitter.js (59 LOC)
│ ├── TokenTextSplitter.js (23 LOC)
│ └── index.js
├── loaders/
│ ├── BaseLoader.js (43 LOC)
│ ├── PDFLoader.js (160 LOC) ← only concrete loader
│ ├── DirectoryLoader.js (stub)
│ ├── TextLoader.js (stub)
│ └── index.js
├── llms/
│ ├── BaseLLM.js (43 LOC)
│ ├── LlamaCpp.js (106 LOC)
│ └── index.js
├── chat-models/
│ └── ChatLlamaCpp.js (32 LOC)
├── utils/
│ ├── Document.js (55 LOC) ← universal payload
│ ├── llama_cpp.js (69 LOC) ← factory functions
│ ├── similarity.js (stub)
│ ├── tokenizer.js (stub)
│ ├── validators.js (stub)
│ └── index.js
├── chains/ retrievers/ vector-stores/ prompts/
│ └── index.js + class .js stubs ← future homes
└── README.md
Configuration Reference
| Parameter | Default | Description |
|---|---|---|
EmbeddingModel.modelPath | (required) | Path to GGUF embedding model (e.g., bge-small-en-v1.5.Q8_0.gguf). |
EmbeddingModel.dimensions | 384 | Expected embedding dimensions. Must match the model. |
EmbeddingModel.batchSize | 32 | Parallel embeds per batch via Promise.all. |
EmbeddingModel.cache | true | Toggle the cache side-channel. |
EmbeddingModel.logLevel | 'error' | Passed to getLlama(); suppresses ggml output. |
EmbeddingCache.maxSize | 10000 | Eviction threshold; LRU drops oldest on insert. |
EmbeddingCache.useHash | true | SHA-256 the text key (privacy + fixed length). |
EmbeddingCache.hashAlgorithm | 'sha256' | Any algo accepted by Node's crypto.createHash. |
BaseTextSplitter.chunkSize | 1000 | Max length per chunk (in lengthFunction units). |
BaseTextSplitter.chunkOverlap | 200 | Overlap chars/tokens between adjacent chunks. |
BaseTextSplitter.lengthFunction | t => t.length | Hook for token-aware splitters. |
RecursiveCharSplitter.separators | ['\n\n','\n','. ',' ',''] | Priority list; first-found wins, fallthrough on oversize. |
LlamaCpp.contextSize | 2048 | Token context window. |
LlamaCpp.batchSize / threads | 512 / 4 | llama.cpp inference parameters. |
LlamaCpp.gpuLayers | (unset) | Offload N layers to GPU; falls back to CPU. |
LlamaCpp.gbnf | (unset) | GBNF grammar string for constrained generation. |
Code Examples
Embed a query and a document
import { EmbeddingModel } from './src/embeddings/EmbeddingModel.js';
const embeddings = new EmbeddingModel({
modelPath: './models/bge-small-en-v1.5.Q8_0.gguf',
cache: true,
batchSize: 32
});
await embeddings.initialize();
const queryVec = await embeddings.embedQuery('What is RAG?');
const docVecs = await embeddings.embedDocuments([
'Retrieval-Augmented Generation combines retrieval with LLMs.',
'The capital of France is Paris.'
]);
const sim = await embeddings.similarity('RAG explained', 'Retrieval-Augmented Generation');
console.log('similarity:', sim); // ~0.85
Split a PDF into overlapping chunks
import { PDFLoader, RecursiveCharacterTextSplitter } from './src/index.js';
const loader = new PDFLoader('https://arxiv.org/pdf/2005.11401', { splitPages: true });
const pages = await loader.load(); // Document[]; one per page
const splitter = new RecursiveCharacterTextSplitter({
chunkSize: 1000,
chunkOverlap: 200,
separators: ['\n\n', '\n', '. ', ' ', '']
});
const chunks = await splitter.splitDocuments(pages);
console.log(`${pages.length} pages -> ${chunks.length} chunks`);
Local LLM completion with grammar-constrained output
import { LlamaCpp } from './src/llms/LlamaCpp.js';
const llm = await LlamaCpp.initialize({
modelPath: './models/Qwen3-1.7B.Q8_0.gguf',
contextSize: 2048,
temperature: 0.2,
maxTokens: 256,
// Optional: constrain output to JSON via GBNF
gbnf: 'root ::= "{" ws "\"answer\":" ws string ws "}"'
});
const response = await llm.invoke('What is RAG?');
console.log(response);
await llm.dispose();
Sub-Module Cards
embeddings/
Text → vectors with caching. Why it exists: dense retrieval needs semantic vectors; the cache makes repeat queries free.
text-splitters/
Chunk long text. Why it exists: embeddings have a max input length; chunks are the indexable unit.
loaders/
Read sources into Document[]. Why it exists: every retrieval pipeline starts with parsing raw bytes.
llms/
Local LLM completion. Why it exists: RAG generates with retrieved context; this is the generation layer.