Skip to content

Advanced Retrieval ​

The personaforge/knowledge module ships everything needed to close the retrieval quality gap that separates a demo RAG pipeline from a production one: real chunking, dense-plus-sparse hybrid search, reranking, and composable retriever primitives.

ts
import {
  // Chunking
  RecursiveCharacterSplitter, MarkdownSplitter, SemanticSplitter,
  // Keyword + hybrid
  BM25Index, HybridRetriever, rrfFuse,
  // Rerankers
  CohereReranker, JinaReranker, LLMReranker,
  // Retriever primitives
  MultiQueryRetriever, ContextualCompressionRetriever, LLMCompressor,
  ParentDocumentRetriever, SelfQueryRetriever, TimeWeightedRetriever,
} from 'personaforge/knowledge';

Everything below is zero-dependency by default. External services (Cohere, Jina) are opt-in.


Text splitters ​

Splitters turn a raw document into overlapping, size-bounded Chunks before embedding. Chunking quality is one of the biggest levers on final answer quality.

RecursiveCharacterSplitter ​

The default. Tries a priority list of separators (paragraph → line → sentence → word → char), recursing into any fragment still larger than chunkSize. Semantic boundaries are preferred.

ts
const splitter = new RecursiveCharacterSplitter({
  chunkSize: 800,     // characters (or tokens if lengthFn supplied)
  chunkOverlap: 100,  // characters kept between adjacent chunks for context
});

const chunks = splitter.splitText(longDocument);
// [{ content: '...', metadata: {}, chunkIndex: 0 }, ...]

For token-accurate chunking pass a counter:

ts
new RecursiveCharacterSplitter({
  chunkSize: 1000,
  lengthFn: (t) => Math.ceil(t.length / 4),  // rough tokens-per-char heuristic
});

MarkdownSplitter ​

Cuts on Markdown headings first (each chunk keeps its heading as context), then falls back to recursive splitting inside oversized sections.

ts
const chunks = new MarkdownSplitter({ chunkSize: 1200 }).splitText(readme);
// Each chunk's metadata.heading is the enclosing section heading.

SemanticSplitter ​

Groups adjacent sentences while their embedding stays similar, and cuts a new chunk when the cosine similarity drops below breakThreshold. Requires an embedding function; use it when your source is prose without heading structure.

ts
const splitter = new SemanticSplitter({
  embed: (text) => embedder.embed(text),
  breakThreshold: 0.5,  // lower = more permissive grouping
  maxChars: 2000,       // hard cap so a semantic run cannot overflow the context window
});
const chunks = await splitter.splitTextAsync(article);

Keyword retrieval: BM25Index ​

Vector cosine similarity does not always find exact-term matches (order IDs, error codes, product names). BM25Index is a zero-dependency Okapi BM25 keyword index that complements dense retrieval.

ts
const bm25 = new BM25Index();
bm25.add(documents);

const hits = bm25.search('reset password link', 10);
// SearchResult[] normalised to 0..1 for comparability with cosine scores

Deliberately in-memory. For >100k documents pair with Elasticsearch or Meilisearch through the same SearchResult interface.


Hybrid retrieval (dense + sparse) with RRF ​

Dense scores and BM25 scores live on different scales, so score-level averaging is unreliable. HybridRetriever fuses rankings, not scores, using Reciprocal-Rank Fusion:

$$ \text{score}(d) = \sum_{i} \frac{1}{k + \text{rank}_i(d)} $$

with k = 60 (Cormack et al. 2009) by default.

ts
const hybrid = new HybridRetriever({
  dense: vectorStore,   // your existing VectorStore
  sparse: bm25,
  k: 60,                // RRF constant; higher = tail contributions matter more
  candidateK: 20,       // pull this many from each list before fusion
});

const results = await hybrid.search('reset password link', 10);

Need to fuse more than two lists (e.g. dense + BM25 + a third source)? Use rrfFuse directly:

ts
const fused = rrfFuse([denseHits, bm25Hits, externalHits], 60).slice(0, 10);

Rerankers ​

A reranker takes the top-N candidates from a cheap retriever and re-scores them with a cross-encoder that sees the (query, document) pair together. It is the single largest quality lever above vanilla cosine similarity.

Cohere ​

ts
const reranker = new CohereReranker({
  apiKey: process.env.COHERE_API_KEY,
  model: 'rerank-english-v3.0',
});
const reranked = await reranker.rerank(query, candidates, 5);

Jina ​

ts
const reranker = new JinaReranker({
  apiKey: process.env.JINA_API_KEY,
  model: 'jina-reranker-v2-base-multilingual',
});

LLM-as-reranker ​

Portable to any chat model (Ollama, Anthropic, Google, OpenAI) without a dedicated rerank endpoint. Runs candidate pairs in parallel with a bounded worker pool.

ts
const reranker = new LLMReranker({
  generate: (prompt) => llm.generate(prompt),
  concurrency: 4,
});

Suggested pipeline: retrieve 20 candidates with hybrid → rerank to top 5 → pass to the LLM.

ts
const candidates = await hybrid.search(query, 20);
const top5 = await reranker.rerank(query, candidates, 5);

Retriever primitives ​

Each primitive implements the same Retriever interface (search(query, topK)), so they compose. Wrap a base retriever in one primitive to add a capability; wrap again to stack.

MultiQueryRetriever ​

Asks an LLM to generate queryCount variants of the user query, runs each through the base retriever, unions and dedupes results. Fixes recall on ambiguous or poorly-phrased queries.

ts
const retriever = new MultiQueryRetriever({
  base: hybrid,
  generate: (prompt) => llm.generate(prompt),
  queryCount: 3,
});

ContextualCompressionRetriever ​

Passes each retrieved chunk through a compressor that extracts only the sentences relevant to the query. Chunks that compress to empty are filtered out.

ts
const retriever = new ContextualCompressionRetriever({
  base: hybrid,
  compressor: new LLMCompressor((prompt) => llm.generate(prompt)),
});

ParentDocumentRetriever ​

Stores small child chunks for precise retrieval but returns their full parent document for richer LLM context. Fixes the common tradeoff between chunk granularity and answer completeness.

ts
const retriever = new ParentDocumentRetriever({ childStore: vectorStore });

await retriever.addDocuments(parents, (parent) => {
  return splitter.splitText(parent.content).map((chunk) => ({
    id: crypto.randomUUID(),
    content: chunk.content,
    metadata: { _parentId: parent.id },
  }));
});

SelfQueryRetriever ​

Uses an LLM to extract metadata filters from the user query, then applies them to the base retriever's candidates. Handles queries like "papers by Karpathy about optimizers".

ts
const retriever = new SelfQueryRetriever({
  base: hybrid,
  generate: (prompt) => llm.generate(prompt),
  fieldDescriptions: {
    author: 'Name of the paper author',
    year: 'Publication year',
    topic: 'Research topic',
  },
});

TimeWeightedRetriever ​

Decays relevance of older documents. Score = base_similarity × decayFactor^(ageInHours). Documents must include metadata.createdAt as a millisecond timestamp.

ts
const retriever = new TimeWeightedRetriever({
  base: hybrid,
  decayFactor: 0.99,   // per-hour multiplier; closer to 1 = slower decay
});

Composing the full pipeline ​

The primitives are designed to stack. A production pipeline typically looks like:

ts
const retriever = new ContextualCompressionRetriever({
  base: new MultiQueryRetriever({
    base: new HybridRetriever({ dense: vectorStore, sparse: bm25 }),
    generate: (p) => llm.generate(p),
  }),
  compressor: new LLMCompressor((p) => llm.generate(p)),
});

// Optional rerank pass on top:
const raw = await retriever.search(query, 20);
const top5 = await reranker.rerank(query, raw, 5);

Each layer is optional. Start with HybridRetriever alone and add wrappers only when eval shows they help.


Testing ​

Every splitter, retriever, and reranker has unit test coverage in tests/retrieval.test.ts (13 tests, all green). The LLMReranker and LLM-based primitives use injectable generate functions, so tests never call a real API.


Released under the MIT License.