Skip to main content
Welcome back. In this lesson we build a practical hybrid RAG (Retrieval-Augmented Generation) pipeline that combines BM25 keyword search with semantic vector search (ChromaDB), fuses results using Reciprocal Rank Fusion (RRF), and uses an Ollama LLM to produce grounded answers. The pipeline ingests a folder of .txt documents (for example, Project Gutenberg books), chunks them, indexes chunks into ChromaDB, persists tokenized chunks for BM25, and executes hybrid queries that blend both retrieval signals to produce a final, source-attributed answer. We use a local Ollama instance for both embeddings and the LLM so everything can run locally. Highlights
  • Chunk and index plain-text books for retrieval.
  • Build a BM25 index for exact keyword matches.
  • Build a vector index (ChromaDB) for semantic matches using Ollama embeddings.
  • Fuse BM25 and vector rankings via RRF to improve robustness.
  • Ask queries that return concise, source-grounded answers from an Ollama chat model.
Overview of the approach
  • Read .txt files from a folder.
  • Chunk the documents and store per-chunk metadata.
  • Create a BM25 corpus (tokenized chunks, persisted as pickle files).
  • Embed chunks with Ollama and upsert into a persistent Chroma collection.
  • On query: run BM25 to get top keyword hits and run Chroma vector search for semantic hits. Merge lists with RRF, fetch fused chunks, construct context, and ask an Ollama chat model to produce a concise answer that cites sources.
We demonstrate this with a public-domain text: Frankenstein by Mary Shelley. I added Frankenstein.txt to the data folder (Project Gutenberg eBook).
The image shows a Visual Studio Code window displaying the text from the Project Gutenberg eBook of "Frankenstein; Or, The Modern Prometheus" by Mary Wollstonecraft Shelley. It includes information about the eBook's usage rights, author, release date, and language.
Callouts — quick setup and warning
Before running the pipeline, install the required Python packages and ensure you have a running local Ollama instance and the Ollama models you plan to use.
Ollama models must be installed locally and served by an Ollama daemon. This demo assumes Ollama is reachable on the default local socket. If Ollama is not running or models are missing, embedding and LLM calls will fail.
Prerequisites
  • Python 3.8+.
  • Local Ollama installed and running with:
    • an embedding model (e.g., nomic-embed-text)
    • an LLM (e.g., llama:3.3:latest)
  • Chroma will store a local persistent collection in .chroma/.
Install Python dependencies:
Key files
  • hybrid_rag.py — single script demonstrating the full pipeline.
  • data/frankenstein.txt — example corpus (Project Gutenberg).
Architecture and component mapping Below is the hybrid_rag.py script presented in logical chunks: imports & constants, embedding helpers, utilities, RRF fusion, ingest, BM25 loader, ask function, and CLI. Note: keep each code block intact when assembling the full file. Imports and constants
Embedding helper wrappers
File reading, chunking, and tokenization utilities
Reciprocal Rank Fusion (RRF) merge
Ingest function — chunk, embed, upsert to Chroma, and save BM25 data
Load BM25 index (tokens and ids)
ask function — run BM25 and vector searches, fuse, fetch, and query LLM
Here’s a screenshot showing the code in editor as we prepare the ask function and the LLM call.
The image shows a Visual Studio Code interface with Python code. It includes a function definition and an autocomplete suggestion box.
CLI entry point — argparse for ingest and ask
Usage examples
  • Ingest a folder (e.g., the data folder with frankenstein.txt):
Expected ingest output (example):
  • Ask a question:
Example output:
Notes about behavior and best practices
  • Why hybrid retrieval? BM25 excels at precise keyword matching (high precision for exact terms). Embedding-based retrieval (semantic search) excels at recall for conceptually related content. Blending both often yields more robust retrieval in realistic applications.
  • RRF is a simple, effective fusion strategy to combine two ranked lists into a single ordered result set.
  • The prompt instructs the LLM to “Answer ONLY using the provided context” to reduce hallucinations. In practice, fine-tune prompts, retrieval sizes (k_each and final_k), and chunk sizes to balance precision and recall.
  • Chunk size and overlap are tunable knobs. For a local demo, a chunk size of ~800 characters with a 150-character overlap worked well; adjust based on your documents and the model context window.
Testing the system
  • Try open-ended queries, e.g., “When does Victor animate the creature?” — the system will either find a grounded answer in the retrieved chunks or reply “I don’t know” when the context lacks a definitive answer.
  • Add more books (e.g., Sherlock Holmes) to the data/ directory and re-run ingest to build a multi-document corpus. Hybrid retrieval will return relevant chunks across documents.
Summary This pipeline demonstrates a practical hybrid RAG setup using:
  • Local embeddings via Ollama,
  • Persistent semantic store via ChromaDB,
  • Keyword retrieval via BM25,
  • Fusion via RRF,
  • Final response generation via an Ollama chat model instructed to use only the retrieved context.
Links and References The repository for this lesson includes the full hybrid_rag.py script and example data/ files so you can run it locally and extend it for your own document corpora.

Watch Video