src/ — The Library

LangChain-shaped, dependency-light, deliberately partial

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

10
sub-modules
6
filled
4
scaffolded stubs
40
.js files
8
npm exports

📚 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

DecisionChoice MadeAlternativesWhy
Class taxonomyLangChain.js naming verbatimCustom names; functional APILower cognitive cost when learners graduate to LangChain proper.
Async setupStatic factory LlamaCpp.initialize() returning the instancenew LlamaCpp(); await x.init();Atomic init prevents "half-constructed" instances escaping; constructors can’t await.
Cache keySHA-256 hash of the text alone(text + modelPath); raw text keySingle-model usage assumed; fixed-length keys simplify on-disk JSON. Hash hides text in dumps unless useHash:false.
Cache evictionLRU by lastAccessed via linear scanLFU; FIFO; doubly-linked-list LRUO(n) eviction is rare on the hot path; readability wins over O(1) bookkeeping for an educational repo.
Splitter overlapmergeSplits shifts the front of current[] while length > chunkOverlapRe-slice from end of previous chunkChar-accurate overlap regardless of separator; the alternative tangles with separator stripping.
Token estimationchars / 4 heuristic in TokenTextSplitterBundle tiktoken; ship a wasm tokenizerZero-dependency. Acceptable error band — real BPE tokens average ~4 chars in English.
ESM throughout"type":"module" + relative ./Foo.js importsCommonJS; 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:

  1. User constructs new PDFLoader(url, {splitPages:true}) — constructor stores source and splitPages; no async work yet (constructors can't await).
  2. User calls await loader.load() — this is where work begins. The loader instantiates PDFParse({url}) from the pdf-parse package.
  3. Loader fetches PDF info via parser.getInfo() — gives us pages count, info.version, metadata. Cheap call (~10ms remote, <1ms local).
  4. Per-page extraction loop calls parser.getText({partial:[i+1]}). Each page text passes through cleanText() — collapse whitespace, cap consecutive newlines at 2.
  5. constructDocument builds the metadata bag: {source, pdf:{version,info,metadata,totalPages}, page:N, loc:{pageNumber:N}}. Returns a Document.
  6. User passes Documents to splitter: await new RecursiveCharacterTextSplitter({chunkSize:1000, chunkOverlap:200}).splitDocuments(docs). Splitter calls createDocuments(texts, metadatas) internally.
  7. 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.
  8. Each split is sized via lengthFunction (default t => t.length). Pieces ≤ chunkSize stack into temp[]; oversize pieces recurse into the next finer separator.
  9. mergeSplits handles overlap: as it accumulates pieces into a chunk, when the chunk hits chunkSize it flushes, then shifts pieces off the front until length ≤ chunkOverlap. The remaining pieces become the prefix of the next chunk.
  10. Chunked Documents inherit metadata plus chunk:j and totalChunks:N. The PDF's page number is preserved — you can later cite the source.
  11. User constructs new EmbeddingModel({modelPath:'./models/bge-small.gguf'}) — sets defaults (dim=384, batchSize=32, cache=true). Model is NOT loaded yet.
  12. await embeddings.initialize() — idempotent. Calls getLlama() → loadModel() → createEmbeddingContext(). ~1.5s cold start, dominates the pipeline.
  13. await embeddings.embedDocuments(texts) — batches into chunks of 32, runs each batch as Promise.all(batch.map(per-text)). Per text: cache lookup (SHA-256 hash) — if hit, return immediately; if miss, call ctx.getEmbeddingFor(text) and write the result back.
  14. Vectors return as number[][]. The user is now responsible for storing them — src/vector-stores/ is empty, so this lesson explicitly delegates to embedded-vector-db in the calling example.

Key Components

ComponentFileRole & Why It Exists
Documentsrc/utils/Document.jsThe universal payload — {pageContent, metadata, id?}. Everything that flows between modules is a Document. Stable interface; changes here ripple to ~70 files.
BaseLoadersrc/loaders/BaseLoader.jsAbstract base for every loader. Defines load(), loadAndSplit(splitter), loadLazy(). Why abstract? Mirrors LangChain shape and signals "subclass me to add file-format support".
PDFLoadersrc/loaders/PDFLoader.jsOnly concrete loader. Wraps pdf-parse. splitPages toggle yields one Document per page or one merged Document.
BaseTextSplittersrc/text-splitters/BaseTextSplitter.jsHolds chunkSize, chunkOverlap, lengthFunction, plus splitDocuments, createDocuments, mergeSplits. Subclasses only override splitText().
RecursiveCharacterTextSplittersrc/text-splitters/RecursiveCharacterTextSplitter.jsThe recommended splitter. Walks ['\n\n','\n','. ',' ',''] and recurses into oversize pieces with finer separators. LangChain-standard.
CharacterTextSplittersrc/text-splitters/CharacterTextSplitter.jsSingle-separator splitter. Useful when text has predictable structure (CSV, log lines).
TokenTextSplittersrc/text-splitters/TokenTextSplitter.jsApproximates token count as chars/4. Why approximate? Avoids tiktoken dependency; lengthFunction seam lets a real tokenizer drop in later.
EmbeddingModelsrc/embeddings/EmbeddingModel.jsLangChain-shaped embedQuery/embedDocuments. Lazy initialize(). Optional injected cache. Built-in cosine similarity helper.
EmbeddingCachesrc/embeddings/EmbeddingCache.jsBounded in-memory cache (LRU) plus JSON disk persistence. Stats include hitRate, evictions. Designed as a side-channel — never throws.
BaseLLMsrc/llms/BaseLLM.jsAbstract: invoke / stream / batch / dispose / getModelType. Throws on direct instantiation.
LlamaCppsrc/llms/LlamaCpp.jsConcrete LLM via node-llama-cpp. Static factory initialize. Optional GBNF grammar. Streaming is currently a placeholder.
ChatLlamaCppsrc/chat-models/ChatLlamaCpp.jsSubclass adding _formatMessagesToPrompt for [{role,content}] arrays. Minimal — proves the inheritance shape works.
llama_cpp.jssrc/utils/llama_cpp.jsFactory 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:

AlgorithmPurposeWhy This OneComplexity
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

FailureHow DetectedRecoveryCascading Effects
Missing GGUF model filenode-llama-cpp throws on loadModel()Surface to user with clear path; user follows DOWNLOAD.mdEmbedder + LLM both fail to initialize. Examples can't run end-to-end.
Invalid chunkOverlap ≥ chunkSizeConstructor throws synchronouslyFail fast at construction, before any workNone — never reaches splitting.
Method called before initialize()_ensureInitialized() throwsRe-call sequence: new + initialize() + embedPrevents NPEs deep in llama.cpp bindings.
Cache disk corruptionJSON.parse throws on loadFromDiskCaller catches; cache stays empty (cold start)Performance hit on first batch; correctness intact.
Cache full at maxSize_evictLRU evicts oldestStats track evictions count for tuningSlight 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

ParameterDefaultDescription
EmbeddingModel.modelPath(required)Path to GGUF embedding model (e.g., bge-small-en-v1.5.Q8_0.gguf).
EmbeddingModel.dimensions384Expected embedding dimensions. Must match the model.
EmbeddingModel.batchSize32Parallel embeds per batch via Promise.all.
EmbeddingModel.cachetrueToggle the cache side-channel.
EmbeddingModel.logLevel'error'Passed to getLlama(); suppresses ggml output.
EmbeddingCache.maxSize10000Eviction threshold; LRU drops oldest on insert.
EmbeddingCache.useHashtrueSHA-256 the text key (privacy + fixed length).
EmbeddingCache.hashAlgorithm'sha256'Any algo accepted by Node's crypto.createHash.
BaseTextSplitter.chunkSize1000Max length per chunk (in lengthFunction units).
BaseTextSplitter.chunkOverlap200Overlap chars/tokens between adjacent chunks.
BaseTextSplitter.lengthFunctiont => t.lengthHook for token-aware splitters.
RecursiveCharSplitter.separators['\n\n','\n','. ',' ','']Priority list; first-found wins, fallthrough on oversize.
LlamaCpp.contextSize2048Token context window.
LlamaCpp.batchSize / threads512 / 4llama.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.