Build a Triple-RAG Engine in Rust
Build a local AI assistant that searches documents using three retrieval strategies.
Introduction
30 Second Summary
You have hundreds of text files on your machine. Notes, research, contacts, meeting logs. When you need to find something, you either scroll endlessly or rely on basic filename search that misses what you actually need.
In this project, you will build a local AI-powered search engine in Rust that indexes your documents three different ways and answers questions about them using a local LLM. No data ever leaves your device.
What You'll Build
A terminal-based personal intelligence engine that searches your documents using three complementary retrieval strategies and generates context-aware answers with a local language model.
By the end of this project, you'll have:
- A triple-store retrieval system that indexes documents using SQLite metadata, Tantivy BM25 keyword search, and LanceDB vector embeddings.
- A query router that intelligently selects which store to search based on the type of question you ask.
- A local LLM chat interface powered by Qwen2.5-1.5B that generates answers from retrieved context, running entirely on your CPU.
- Secret Mission: Add hybrid scoring with Reciprocal Rank Fusion to combine and rank results from all three stores simultaneously.
Are there any prerequisites?
Basic programming knowledge is all you need. Familiarity with Rust syntax from tutorials is helpful, but the project teaches concepts as you go.
You will need Rust installed on your machine. Step 1 walks you through updating it and installing the required build tools.
Update Rust and Install Build Dependencies
Building a Rust project that compiles native C++ code under the hood requires more than just Rust itself.
The local LLM library we'll use (llama-cpp-4) wraps the C++ project llama.cpp. To compile it, your machine needs clang (for generating Rust-to-C bindings) and a C++17-capable compiler like GCC. In this step you'll make sure all three tools are ready.
In this step, get ready to:
- Update your Rust toolchain to the latest stable release.
- Install clang and GCC with C++17 support.
- Verify all three tools are working with version checks.
Update your Rust toolchain
Rust ships updates frequently. The crates in this project require Rust 1.75 or newer, so you need to bring your toolchain up to the latest stable release.
- Update Rust to the latest stable version by running this command:
rustup update stable
You should see output showing the latest stable version being downloaded and installed.
- Confirm the update succeeded by checking the version:
rustc --version
✔️ I see a version number like 1.8x or higher
Your Rust toolchain is up to date. Move on to the next substep.
ⓧ I see a version lower than 1.75
Your update may not have applied correctly. Try running the update command again:
rustup update stable
If the version still shows below 1.75, check that your PATH points to the rustup-managed toolchain and not a system-installed Rust.
ⓧ Command not found
You don't have rustup installed. Install it by running:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
Follow the on-screen prompts, then restart your terminal and try rustc --version again.
Install clang and GCC
The llama-cpp-4 crate uses clang to generate Rust-to-C bindings at compile time. It also needs a C++17 compiler (GCC 9+ or Clang 10+) to build the underlying llama.cpp source. CMake is not required.
macOS
On macOS, the Xcode Command Line Tools provide both clang and a C++17-capable compiler.
- Install the Xcode Command Line Tools by running:
xcode-select --install
What does this command do?
This opens a system dialog that downloads Apple's developer toolchain. It includes clang, make, and other build tools. If you already have them installed, it will tell you so.
If you see a message that the tools are already installed, you're good to go.
Windows
On Windows, install LLVM (which provides clang) and a C++17 compiler via Visual Studio Build Tools or MSYS2.
- Download and install LLVM from the official releases page at https://github.com/llvm/llvm-project/releases.
- During installation, select the option to add LLVM to your system PATH.
- Install Visual Studio Build Tools with the "Desktop development with C++" workload from https://visualstudio.microsoft.com/visual-cpp-build-tools/.
This gives you both clang and the MSVC C++17 toolchain.
Linux
On Ubuntu/Debian, install both tools with a single command.
- Install clang and GCC by running:
sudo apt update && sudo apt install clang build-essential
What does this install?
The clang package provides the Clang compiler needed for bindgen. The build-essential meta-package installs gcc, g++, and make.
Verify all tools are working
Before moving on, confirm that all three build dependencies respond to a version check.
- Check that clang is available by running:
clang --version
- Check that GCC is available by running:
gcc --version
Both commands should print a version number. For GCC, you need version 9 or higher to get C++17 support.
Not seeing a version number?
If clang --version fails on macOS, try running xcode-select --install again. On Linux, double-check that you ran the sudo apt install clang build-essential command.
If gcc --version shows a version below 9, you'll need to install a newer GCC. On Ubuntu, run sudo apt install gcc-12 g++-12 to get a version with full C++17 support.
Help me troubleshoot my build tools
Your build environment is locked and loaded. Next up, you'll create the Rust project and download the AI models that power the engine.
Create the Project and Download Models
With your Rust toolchain and build dependencies ready, it's time to scaffold the project and grab the AI models that power the engine.
Your triple-RAG system needs two models. One generates text embeddings for semantic search. The other generates natural language answers from retrieved context.
In this step, get ready to:
- Create a Rust project and configure all dependencies.
- Download the Qwen2.5-1.5B LLM for answer generation.
- Download the all-MiniLM-L6-v2 ONNX model for text embeddings.
Create the Rust project and add dependencies
Cargo is Rust's package manager and build system. You will create a new project and then replace the default Cargo.toml with the full dependency list your engine needs.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder by running this command:
cargo new triple-rag
cd triple-rag
What does this command do?
cargo new triple-rag scaffolds a new Rust project with a src/main.rs file and a Cargo.toml manifest. The cd triple-rag command moves you into the new project directory.
- Confirm the project was created by running:
ls
You should see Cargo.toml and a src directory in the output.
Now you need to replace the default Cargo.toml with one that lists all your project dependencies.
- Open Cargo.toml in your text editor.
- Replace the entire contents with the following:
[package]
name = "triple-rag"
version = "0.1.0"
edition = "2021"
[dependencies]
rusqlite = { version = "0.31", features = ["bundled"] }
tantivy = "0.22"
lancedb = "0.12"
ort = { version = "2.0", features = ["download-binaries"] }
llama-cpp-4 = "0.1"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
anyhow = "1"
arrow2 = "52"
What does each dependency do?
- rusqlite provides SQLite bindings for your metadata store. The bundled feature compiles SQLite from source so you don't need it pre-installed.
- tantivy is a full-text search engine library with BM25 scoring for keyword-based retrieval.
- lancedb is an embedded vector database for storing and querying document embeddings.
- ort wraps ONNX Runtime for running the embedding model inference.
- llama-cpp-4 provides Rust bindings to llama.cpp for running the quantized LLM locally.
- tokio is an async runtime needed by LanceDB for async operations.
- serde and serde_json handle serialization for document metadata.
- anyhow provides ergonomic error handling across the project.
- arrow2 supplies the Apache Arrow columnar format that LanceDB uses internally.
- Save Cargo.toml.
Seeing a TOML parse error?
Make sure you replaced the entire file contents, not just appended to the existing content. The [package] section should appear exactly once at the top.
help me fix a TOML parse error in my Cargo.toml
Download the Qwen2.5-1.5B LLM
The Qwen2.5-1.5B-Instruct model is a small but capable instruction-following LLM. You will use the Q4_K_M quantization, which compresses the model to ~986 MB while maintaining good quality for RAG tasks.
- Create a models directory to store both models by running:
mkdir models
- Download the quantized LLM by running this command:
wget -O models/qwen2.5-1.5b-instruct-q4_k_m.gguf https://huggingface.co/bartowski/Qwen2.5-1.5B-Instruct-GGUF/resolve/main/Qwen2.5-1.5B-Instruct-Q4_K_M.gguf
What is a GGUF model?
GGUF is a binary format for storing quantized large language models. Quantization reduces model precision (from 16-bit to 4-bit in this case) to shrink file size and memory usage while preserving most of the model's capabilities.
The Q4_K_M variant is a good balance of size (~986 MB) and quality for CPU inference on a typical laptop.
This download is approximately 986 MB. It may take several minutes depending on your internet speed.
- Confirm the download completed by running:
ls -lh models/qwen2.5-1.5b-instruct-q4_k_m.gguf
You should see the file listed at approximately 986 MB (shown as something like 986M or 0.96G).
Download interrupted or file is 0 bytes?
Large model downloads can fail on unstable connections. Delete the partial file and retry the wget command. If wget is not available on your system, use curl -L -o instead.
help me troubleshoot a failed model download
Download the embedding model
The all-MiniLM-L6-v2 model converts text into 384-dimensional vector embeddings. These embeddings capture the semantic meaning of text, allowing the engine to find documents that are conceptually similar to a query even when they use different words.
- Download the ONNX embedding model by running:
wget -O models/all-MiniLM-L6-v2.onnx https://huggingface.co/optimum/all-MiniLM-L6-v2/resolve/main/model.onnx
Why ONNX format?
ONNX (Open Neural Network Exchange) is a portable format for machine learning models. The ort crate loads ONNX models directly for fast CPU inference without needing Python or PyTorch installed.
This download is approximately 90 MB and should complete quickly.
- Confirm the embedding model downloaded by running:
ls -lh models/all-MiniLM-L6-v2.onnx
You should see the file listed at approximately 91 MB.
Seeing a 404 or connection error?
HuggingFace model downloads require an active internet connection. If wget reports a 404, double-check the URL has no extra spaces or line breaks. You can also try opening the URL directly in your browser to confirm it resolves.
help me download the ONNX embedding model
✔️ Awesome, I've got everything!
Great. Double check you have saved Cargo.toml and both model files are present in the models/ directory.
ⓧ I'd like to double check the full code
Here is what your Cargo.toml should look like:
[package]
name = "triple-rag"
version = "0.1.0"
edition = "2021"
[dependencies]
rusqlite = { version = "0.31", features = ["bundled"] }
tantivy = "0.22"
lancedb = "0.12"
ort = { version = "2.0", features = ["download-binaries"] }
llama-cpp-4 = "0.1"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
anyhow = "1"
arrow2 = "52"
Your models/ directory should contain two files:
- qwen2.5-1.5b-instruct-q4_k_m.gguf (~986 MB).
- all-MiniLM-L6-v2.onnx (~90 MB).
Your project skeleton is ready and both AI models are on disk. Next up, you will build the first retrieval store using SQLite for structured metadata queries.
Build the SQLite Metadata Store
Your project is scaffolded and both models are downloaded. Now it is time to build the first of three retrieval stores.
The SQLite metadata store handles structured queries. When a user asks "what documents did I add last week?" or "find files named meeting-notes", exact-match lookups on dates and filenames are faster and more reliable than vector search. This store gives your engine that structured backbone.
In this step, get ready to:
- Create the documents schema and connect to SQLite using rusqlite.
- Implement document ingestion that extracts metadata (filename, date, word count).
- Implement structured query functions for searching by filename and date range.
Set up rusqlite and create the documents schema
The metadata store needs a dedicated Rust module. You will create a new file for it and register it in main.rs.
- Replace the contents of src/main.rs with the following code to register the new module:
mod sqlite_store;
fn main() {
println!("Triple-RAG engine starting...");
}
What does this code do?
The mod sqlite_store; declaration tells Rust to look for a file called src/sqlite_store.rs and compile it as part of your project. Without this line, Rust ignores the file entirely.
- Save src/main.rs.
- Create a new file called src/sqlite_store.rs in the same folder.
- Add the imports and the DocumentMetadata struct by pasting this code into src/sqlite_store.rs:
use rusqlite::{Connection, Result, params};
use anyhow::anyhow;
// Represents a single document's metadata for structured queries
#[derive(Debug, Clone)]
pub struct DocumentMetadata {
pub id: Option<i64>,
pub filename: String,
pub date_created: String,
pub word_count: usize,
pub content: String,
}
What does this code do?
- The rusqlite imports bring in Connection (the database handle), Result (rusqlite's result type), and params! (a macro for binding query parameters).
- DocumentMetadata defines the shape of every document you ingest. The id is Option<i64> because SQLite assigns it automatically on insert.
- The #[derive(Debug, Clone)] attribute lets you print and copy structs easily during development.
You won't see a change yet. The next chunk uses this struct.
- Below the DocumentMetadata struct in src/sqlite_store.rs, add the SqliteStore struct and its constructor:
// Wraps a rusqlite Connection for structured metadata queries
pub struct SqliteStore {
conn: Connection,
}
impl SqliteStore {
// Opens (or creates) the database file and ensures the table exists
pub fn new(db_path: &str) -> anyhow::Result<Self> {
let conn = Connection::open(db_path)?;
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS documents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
filename TEXT NOT NULL,
date_created TEXT NOT NULL,
word_count INTEGER NOT NULL,
content TEXT NOT NULL
);"
)?;
Ok(Self { conn })
}
}
What does this code do?
- SqliteStore wraps a Connection so the rest of your code can call methods on the store without managing the raw database handle.
- The new() constructor opens (or creates) a SQLite database file at the given path, then runs CREATE TABLE IF NOT EXISTS so the schema is ready on first launch.
- The ? operator propagates errors up through anyhow::Result, giving you readable error messages if something goes wrong.
- Save src/sqlite_store.rs.
- Verify the schema code compiles by running:
cargo check
You should see a successful check (possibly with unused import warnings). No errors means your schema and struct are valid Rust.
Seeing a compilation error?
- Make sure src/sqlite_store.rs is in the src/ folder (not the project root). Rust expects modules declared with mod sqlite_store; to live at src/sqlite_store.rs.
- Check that main.rs has mod sqlite_store; at the very top, before fn main().
- If you see an error about the bundled feature, confirm your Cargo.toml has rusqlite = { version = "0.31", features = ["bundled"] }.
Help me debug my cargo check error for the SQLite store module.
Implement document ingestion
With the schema in place, the store needs a way to accept new documents. The insert_document method extracts metadata from a DocumentMetadata struct and writes it into the table.
- In src/sqlite_store.rs, find the closing } of the impl SqliteStore block (after the new() function).
- Add this method inside the impl block, just before that closing }:
// Inserts a document and returns its auto-generated row ID
pub fn insert_document(&self, doc: &DocumentMetadata) -> anyhow::Result<i64> {
self.conn.execute(
"INSERT INTO documents (filename, date_created, word_count, content) VALUES (?1, ?2, ?3, ?4)",
params![doc.filename, doc.date_created, doc.word_count as i64, doc.content],
)?;
Ok(self.conn.last_insert_rowid())
}
What does this code do?
- The params! macro binds Rust values to the ?1, ?2, ?3, ?4 placeholders in the SQL. This prevents SQL injection and handles type conversion automatically.
- The word_count as i64 cast converts Rust's usize to an integer SQLite understands.
- After inserting, last_insert_rowid() returns the auto-incremented ID so callers know which row was created.
- Save src/sqlite_store.rs.
You won't see a visible change yet. The next chunk adds the query methods that read data back out.
Implement structured query functions
The metadata store needs three retrieval methods. One searches by filename pattern, one filters by date range, and one returns every document. Together they cover the structured queries your router will dispatch to this store.
- In src/sqlite_store.rs, add the search_by_filename method inside the impl SqliteStore block, below insert_document:
// Searches documents whose filename contains the given pattern
pub fn search_by_filename(&self, pattern: &str) -> anyhow::Result<Vec<DocumentMetadata>> {
let mut stmt = self.conn.prepare(
"SELECT id, filename, date_created, word_count, content FROM documents WHERE filename LIKE ?1"
)?;
let docs = stmt.query_map(params![format!("%{}%", pattern)], |row| {
Ok(DocumentMetadata {
id: Some(row.get(0)?),
filename: row.get(1)?,
date_created: row.get(2)?,
word_count: row.get::<_, i64>(3)? as usize,
content: row.get(4)?,
})
})?.filter_map(|r| r.ok()).collect();
Ok(docs)
}
What does this code do?
- The SQL LIKE ?1 clause with %pattern% wrapping performs a substring match on filenames. If the user asks for "meeting", it matches meeting-notes.txt, quarterly-meeting.md, etc.
- The query_map method runs the query and maps each row into a DocumentMetadata struct. The filter_map(|r| r.ok()) skips any rows that fail to parse, keeping the pipeline resilient.
- Save src/sqlite_store.rs.
- You won't see a change yet. The next two methods follow the same pattern.
- Add the search_by_date_range method below search_by_filename:
// Filters documents created between two dates (inclusive, ISO 8601 strings)
pub fn search_by_date_range(&self, start: &str, end: &str) -> anyhow::Result<Vec<DocumentMetadata>> {
let mut stmt = self.conn.prepare(
"SELECT id, filename, date_created, word_count, content FROM documents WHERE date_created BETWEEN ?1 AND ?2"
)?;
let docs = stmt.query_map(params![start, end], |row| {
Ok(DocumentMetadata {
id: Some(row.get(0)?),
filename: row.get(1)?,
date_created: row.get(2)?,
word_count: row.get::<_, i64>(3)? as usize,
content: row.get(4)?,
})
})?.filter_map(|r| r.ok()).collect();
Ok(docs)
}
What does this code do?
This method uses SQL BETWEEN to filter on the date_created column. Because dates are stored as ISO 8601 strings (e.g. 2026-05-01), alphabetical comparison produces correct chronological ordering. No date parsing library needed.
- Save, then add the final method get_all_documents below search_by_date_range:
// Returns every document in the store (useful for full-context queries)
pub fn get_all_documents(&self) -> anyhow::Result<Vec<DocumentMetadata>> {
let mut stmt = self.conn.prepare(
"SELECT id, filename, date_created, word_count, content FROM documents"
)?;
let docs = stmt.query_map([], |row| {
Ok(DocumentMetadata {
id: Some(row.get(0)?),
filename: row.get(1)?,
date_created: row.get(2)?,
word_count: row.get::<_, i64>(3)? as usize,
content: row.get(4)?,
})
})?.filter_map(|r| r.ok()).collect();
Ok(docs)
}
What does this code do?
This is the simplest retrieval path. It has no WHERE clause and returns every row. Your query router will call this when the user asks a broad question like "summarize everything I've stored."
- Save src/sqlite_store.rs.
- Run a final compilation check:
cargo check
You should see no errors. You may still see unused import warnings for Result and anyhow. That is fine for now.
Seeing type mismatch or missing method errors?
- Make sure every method is inside the impl SqliteStore { ... } block. If a method is accidentally outside the closing }, Rust won't associate it with the struct.
- Check that each row.get::<_, i64>(3)? uses the turbofish syntax ::<_, i64> to tell Rust the column is an integer before casting to usize.
- Confirm use rusqlite::{Connection, Result, params}; is at the top of the file with no typos.
Help me fix a compilation error in my SQLite store query methods.
✔️ Awesome, I've got everything!
Great. Double check you have saved both src/main.rs and src/sqlite_store.rs.
ⓧ I'd like to double check the full code
Here is the complete src/main.rs:
mod sqlite_store;
fn main() {
println!("Triple-RAG engine starting...");
}
Here is the complete src/sqlite_store.rs:
use rusqlite::{Connection, Result, params};
use anyhow::anyhow;
#[derive(Debug, Clone)]
pub struct DocumentMetadata {
pub id: Option<i64>,
pub filename: String,
pub date_created: String,
pub word_count: usize,
pub content: String,
}
pub struct SqliteStore {
conn: Connection,
}
impl SqliteStore {
pub fn new(db_path: &str) -> anyhow::Result<Self> {
let conn = Connection::open(db_path)?;
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS documents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
filename TEXT NOT NULL,
date_created TEXT NOT NULL,
word_count INTEGER NOT NULL,
content TEXT NOT NULL
);"
)?;
Ok(Self { conn })
}
pub fn insert_document(&self, doc: &DocumentMetadata) -> anyhow::Result<i64> {
self.conn.execute(
"INSERT INTO documents (filename, date_created, word_count, content) VALUES (?1, ?2, ?3, ?4)",
params![doc.filename, doc.date_created, doc.word_count as i64, doc.content],
)?;
Ok(self.conn.last_insert_rowid())
}
pub fn search_by_filename(&self, pattern: &str) -> anyhow::Result<Vec<DocumentMetadata>> {
let mut stmt = self.conn.prepare(
"SELECT id, filename, date_created, word_count, content FROM documents WHERE filename LIKE ?1"
)?;
let docs = stmt.query_map(params![format!("%{}%", pattern)], |row| {
Ok(DocumentMetadata {
id: Some(row.get(0)?),
filename: row.get(1)?,
date_created: row.get(2)?,
word_count: row.get::<_, i64>(3)? as usize,
content: row.get(4)?,
})
})?.filter_map(|r| r.ok()).collect();
Ok(docs)
}
pub fn search_by_date_range(&self, start: &str, end: &str) -> anyhow::Result<Vec<DocumentMetadata>> {
let mut stmt = self.conn.prepare(
"SELECT id, filename, date_created, word_count, content FROM documents WHERE date_created BETWEEN ?1 AND ?2"
)?;
let docs = stmt.query_map(params![start, end], |row| {
Ok(DocumentMetadata {
id: Some(row.get(0)?),
filename: row.get(1)?,
date_created: row.get(2)?,
word_count: row.get::<_, i64>(3)? as usize,
content: row.get(4)?,
})
})?.filter_map(|r| r.ok()).collect();
Ok(docs)
}
pub fn get_all_documents(&self) -> anyhow::Result<Vec<DocumentMetadata>> {
let mut stmt = self.conn.prepare(
"SELECT id, filename, date_created, word_count, content FROM documents"
)?;
let docs = stmt.query_map([], |row| {
Ok(DocumentMetadata {
id: Some(row.get(0)?),
filename: row.get(1)?,
date_created: row.get(2)?,
word_count: row.get::<_, i64>(3)? as usize,
content: row.get(4)?,
})
})?.filter_map(|r| r.ok()).collect();
Ok(docs)
}
}
Your first retrieval store is complete. It handles exact structured lookups. Next up, you will build the Tantivy full-text search index for keyword-based retrieval using BM25 scoring.
Build the Tantivy Full-Text Search Index
Your SQLite metadata store handles structured queries like date ranges and filename patterns. But what about when you want to search the actual content of your documents with natural keywords?
In this step, you will build the second retrieval store using Tantivy, a full-text search engine written in Rust. Tantivy uses BM25 scoring to rank documents by keyword relevance. This is the same algorithm that powers search engines like Elasticsearch and Solr.
In this step, get ready to:
- Create a Tantivy schema with text fields and build the index.
- Implement document indexing with BM25 scoring.
- Implement keyword search with natural query parsing.
Create the Tantivy schema and index
Tantivy requires a strict schema defined upfront. Each field must declare whether it is indexed (searchable) and stored (retrievable after search).
- Create a new file called src/tantivy_store.rs in your triple-rag project.
- Add the imports, struct definition, and constructor by pasting this code:
use tantivy::{Index, IndexWriter, TantivyDocument, collector::TopDocs, query::QueryParser, schema::*};
use anyhow::anyhow;
use std::path::Path;
pub struct TantivyStore {
index: Index,
writer: IndexWriter,
schema: Schema,
doc_id_field: Field,
filename_field: Field,
content_field: Field,
}
impl TantivyStore {
pub fn new(index_path: &str) -> anyhow::Result<Self> {
// Define the schema with three fields
let mut schema_builder = Schema::builder();
let doc_id_field = schema_builder.add_u64_field("doc_id", STORED);
let filename_field = schema_builder.add_text_field("filename", TEXT | STORED);
let content_field = schema_builder.add_text_field("content", TEXT | STORED);
let schema = schema_builder.build();
// Create or open the index directory
let path = Path::new(index_path);
std::fs::create_dir_all(path)?;
let index = if path.join("meta.json").exists() {
Index::open_in_dir(path)?
} else {
Index::create_in_dir(path, schema.clone())?
};
// Allocate 50MB of memory for the indexing buffer
let writer = index.writer(50_000_000)?;
Ok(Self { index, writer, schema, doc_id_field, filename_field, content_field })
}
}
What does this code do?
The struct holds handles to the index, writer, schema, and each field. This avoids repeated lookups later when indexing or searching.
The new() constructor defines three fields. doc_id is a stored numeric ID. filename and content use TEXT | STORED, meaning they are tokenized, indexed with term frequencies, and retrievable from search results.
The index checks for an existing meta.json file. If it finds one, it opens the existing index. Otherwise, it creates a fresh one. The 50 MB writer buffer controls how much memory Tantivy uses for indexing before flushing to disk.
Why BM25 instead of simple substring matching?
BM25 ranks documents by how relevant they are to a query, factoring in term frequency and document length. A document that mentions your search term five times in a short paragraph scores higher than one that mentions it once in a long essay.
Substring matching returns binary yes/no results. BM25 returns ranked results with scores, which is critical for RAG systems that need to pick the most relevant context.
Add the document indexing methods
With the schema in place, you need methods to add documents into the index and flush them to disk.
- In src/tantivy_store.rs, find the closing } of the impl TantivyStore block (after the new() function).
- Add these two methods inside the impl block, below new():
pub fn index_document(&mut self, doc_id: u64, filename: &str, content: &str) -> anyhow::Result<()> {
// Build a TantivyDocument with each field populated
let mut doc = TantivyDocument::default();
doc.add_u64(self.doc_id_field, doc_id);
doc.add_text(self.filename_field, filename);
doc.add_text(self.content_field, content);
self.writer.add_document(doc)?;
Ok(())
}
pub fn commit(&mut self) -> anyhow::Result<()> {
// Flush all pending documents to disk
self.writer.commit()?;
Ok(())
}
What does this code do?
The index_document() method creates a TantivyDocument and populates it field by field. Tantivy tokenizes the content on the fly as it adds the document to the write buffer.
The commit() method flushes all buffered documents to the index on disk. Documents are not searchable until you commit. This batching approach makes indexing many documents efficient.
Add the keyword search method
The search method is where BM25 scoring happens. Tantivy's query parser takes natural language queries and converts them into structured searches across your indexed fields.
- Still inside the impl TantivyStore block in src/tantivy_store.rs, add the search method below commit():
pub fn search(&self, query_str: &str, top_k: usize) -> anyhow::Result<Vec<(String, String, f32)>> {
// Create a reader and searcher for querying
let reader = self.index.reader()?;
let searcher = reader.searcher();
// Parse the query against the content field
let query_parser = QueryParser::for_index(&self.index, vec![self.content_field]);
let query = query_parser.parse_query(query_str)?;
// Execute the search with BM25 ranking
let top_docs = searcher.search(&query, &TopDocs::with_limit(top_k))?;
let mut results = Vec::new();
for (score, doc_address) in top_docs {
let doc: TantivyDocument = searcher.doc(doc_address)?;
let filename = doc.get_first(self.filename_field)
.and_then(|v| v.as_str()).unwrap_or("").to_string();
let content = doc.get_first(self.content_field)
.and_then(|v| v.as_str()).unwrap_or("").to_string();
results.push((filename, content, score));
}
Ok(results)
}
What does this code do?
The QueryParser is configured to search the content_field by default. When you type a natural query like "meeting notes from March", Tantivy tokenizes it and matches against the inverted index.
The TopDocs::with_limit(top_k) collector returns only the highest-scoring documents. Each result includes the BM25 score as an f32, which the query router will use later to decide how confident this retrieval path is.
- Save src/tantivy_store.rs.
Now register the new module so the compiler knows about it.
- In src/main.rs, find this line:
mod sqlite_store;
- Add the Tantivy module declaration directly below it, so the top of the file looks like this:
mod sqlite_store;
mod tantivy_store;
fn main() {
println!("Triple-RAG engine starting...");
}
- Save src/main.rs.
- Verify the project compiles by running:
cargo build
You should see the project compile successfully with no errors. There may be unused-import warnings, which is fine for now.
Seeing compilation errors?
If you see an error about TantivyDocument not being found, make sure your import line at the top of src/tantivy_store.rs matches exactly. The TantivyDocument type must be imported directly from the tantivy crate.
If you see errors about TEXT or STORED not being in scope, check that your import includes schema::* which brings all schema flags into scope.
help me fix Tantivy compilation errors in my triple-rag project
✔️ Awesome, I've got everything!
Great. Double check you have saved both src/tantivy_store.rs and src/main.rs.
ⓧ I'd like to double check the full code
Here is the complete src/tantivy_store.rs file:
use tantivy::{Index, IndexWriter, TantivyDocument, collector::TopDocs, query::QueryParser, schema::*};
use anyhow::anyhow;
use std::path::Path;
pub struct TantivyStore {
index: Index,
writer: IndexWriter,
schema: Schema,
doc_id_field: Field,
filename_field: Field,
content_field: Field,
}
impl TantivyStore {
pub fn new(index_path: &str) -> anyhow::Result<Self> {
let mut schema_builder = Schema::builder();
let doc_id_field = schema_builder.add_u64_field("doc_id", STORED);
let filename_field = schema_builder.add_text_field("filename", TEXT | STORED);
let content_field = schema_builder.add_text_field("content", TEXT | STORED);
let schema = schema_builder.build();
let path = Path::new(index_path);
std::fs::create_dir_all(path)?;
let index = if path.join("meta.json").exists() {
Index::open_in_dir(path)?
} else {
Index::create_in_dir(path, schema.clone())?
};
let writer = index.writer(50_000_000)?;
Ok(Self { index, writer, schema, doc_id_field, filename_field, content_field })
}
pub fn index_document(&mut self, doc_id: u64, filename: &str, content: &str) -> anyhow::Result<()> {
let mut doc = TantivyDocument::default();
doc.add_u64(self.doc_id_field, doc_id);
doc.add_text(self.filename_field, filename);
doc.add_text(self.content_field, content);
self.writer.add_document(doc)?;
Ok(())
}
pub fn commit(&mut self) -> anyhow::Result<()> {
self.writer.commit()?;
Ok(())
}
pub fn search(&self, query_str: &str, top_k: usize) -> anyhow::Result<Vec<(String, String, f32)>> {
let reader = self.index.reader()?;
let searcher = reader.searcher();
let query_parser = QueryParser::for_index(&self.index, vec![self.content_field]);
let query = query_parser.parse_query(query_str)?;
let top_docs = searcher.search(&query, &TopDocs::with_limit(top_k))?;
let mut results = Vec::new();
for (score, doc_address) in top_docs {
let doc: TantivyDocument = searcher.doc(doc_address)?;
let filename = doc.get_first(self.filename_field)
.and_then(|v| v.as_str()).unwrap_or("").to_string();
let content = doc.get_first(self.content_field)
.and_then(|v| v.as_str()).unwrap_or("").to_string();
results.push((filename, content, score));
}
Ok(results)
}
}
And here is the complete src/main.rs file:
mod sqlite_store;
mod tantivy_store;
fn main() {
println!("Triple-RAG engine starting...");
}
Your full-text search store is ready. Next up, you will build the third and final retrieval store using vector embeddings for semantic similarity search.
Build the LanceDB Vector Store with Embeddings
Your SQLite store handles structured queries about filenames and dates. Your Tantivy index handles keyword matching. But neither can answer "find documents that talk about something similar to this concept" because they don't understand meaning.
Semantic search solves this by converting text into numerical vectors (embeddings) that capture meaning. In this step, you will load an ONNX embedding model, chunk documents into overlapping windows, and store those vectors in LanceDB for cosine similarity search.
In this step, get ready to:
- Load the all-MiniLM-L6-v2 ONNX model and generate 384-dimensional embeddings.
- Split documents into overlapping chunks for better retrieval.
- Store and search vectors in LanceDB using cosine similarity.
Set up the ort crate and load the ONNX model
The ort crate wraps ONNX Runtime, giving you fast inference for pre-trained models. You will create an EmbeddingModel struct that loads the all-MiniLM-L6-v2 model you downloaded earlier and exposes it for embedding text.
- Create a new file src/vector_store.rs in your project's src/ folder.
- Add the following imports and EmbeddingModel struct with its constructor:
use ort::{Environment, Session, SessionBuilder, Value};
use lancedb::{connect, Connection as LanceConnection};
use anyhow::anyhow;
use std::sync::Arc;
pub struct EmbeddingModel {
session: Session,
}
impl EmbeddingModel {
pub fn new(model_path: &str) -> anyhow::Result<Self> {
// Initialize the ONNX Runtime environment
let environment = Arc::new(Environment::builder().with_name("embeddings").build()?);
// Load the all-MiniLM-L6-v2 model from disk
let session = SessionBuilder::new(&environment)?.with_model_from_file(model_path)?;
Ok(Self { session })
}
}
What does this code do?
The Environment initializes ONNX Runtime's global state. You only need one per process, wrapped in Arc for safe sharing.
The SessionBuilder loads your .onnx model file and prepares it for inference. Once loaded, you can feed text through it to produce 384-dimensional embedding vectors.
- Save src/vector_store.rs.
You won't see a change yet. The next chunk adds the embedding logic that uses this model.
Implement text embedding and chunking
Embedding models have a maximum input length. The all-MiniLM-L6-v2 model handles up to 256 tokens. To handle documents longer than that, you will split them into overlapping chunks so that context at chunk boundaries is not lost.
- In src/vector_store.rs, find the impl EmbeddingModel block. Add this embed() method below the closing brace of new(), but still inside the impl block:
pub fn embed(&self, text: &str) -> anyhow::Result<Vec<f32>> {
// Simplified tokenization: whitespace split, truncate to 128 tokens
let tokens: Vec<i64> = text.split_whitespace()
.take(128)
.enumerate()
.map(|(i, _)| i as i64 + 1)
.collect();
let len = tokens.len();
let input_ids = Value::from_array(([1usize, len], tokens.as_slice()))?;
let attention_mask: Vec<i64> = vec![1i64; len];
let mask = Value::from_array(([1usize, len], attention_mask.as_slice()))?;
let token_type_ids: Vec<i64> = vec![0i64; len];
let type_ids = Value::from_array(([1usize, len], token_type_ids.as_slice()))?;
let outputs = self.session.run(vec![input_ids, mask, type_ids])?;
// Mean pool the last hidden state (shape: [1, seq_len, 384])
let embeddings = outputs[0].try_extract::<f32>()?;
let view = embeddings.view();
let shape = view.shape();
let seq_len = shape[1];
let hidden = shape[2];
let flat: Vec<f32> = view.iter().cloned().collect();
let mut pooled = vec![0f32; hidden];
for i in 0..seq_len {
for j in 0..hidden {
pooled[j] += flat[i * hidden + j];
}
}
let norm: f32 = pooled.iter().map(|x| x * x).sum::<f32>().sqrt().max(1e-9);
Ok(pooled.iter().map(|x| x / norm / seq_len as f32).collect())
}
What does this code do?
This method converts text into a fixed-size numeric vector. It performs three key operations:
- Tokenization splits the input on whitespace and truncates to 128 tokens. This is simplified compared to a full WordPiece tokenizer, but works for demonstration.
- Inference runs the tokens through the ONNX model with three standard BERT inputs: input_ids, attention_mask, and token_type_ids.
- Mean pooling averages all token embeddings into a single 384-dimensional vector, then normalizes it so cosine similarity works correctly.
Now add the chunking function. This splits long documents into overlapping windows so each piece fits the model's input limit.
- Below the closing } of impl EmbeddingModel, add this standalone function:
pub fn chunk_text(text: &str, chunk_size: usize, overlap: usize) -> Vec<String> {
// Split text into words and create overlapping windows
let words: Vec<&str> = text.split_whitespace().collect();
let mut chunks = Vec::new();
let mut start = 0;
while start < words.len() {
let end = (start + chunk_size).min(words.len());
chunks.push(words[start..end].join(" "));
if end == words.len() { break; }
start += chunk_size - overlap;
}
chunks
}
Why overlapping chunks?
If you split a document at exactly every 256 words, important context at the boundary gets severed. Overlap means adjacent chunks share some words, so a search query that touches a boundary still finds the relevant chunk.
For example, with chunk_size: 256 and overlap: 50, each chunk shares 50 words with its neighbor.
- Save src/vector_store.rs.
You won't see output yet. The next substep connects these embeddings to LanceDB for storage and retrieval.
Store embeddings in LanceDB and implement search
LanceDB is a vector database that stores embeddings alongside metadata and supports fast cosine similarity search. You will build a VectorStore struct that inserts embedded chunks and retrieves the most semantically similar results for any query.
- In src/vector_store.rs, below the chunk_text() function, add the VectorStore struct and its constructor:
pub struct VectorStore {
db: LanceConnection,
table_name: String,
}
impl VectorStore {
pub async fn new(db_path: &str) -> anyhow::Result<Self> {
// Connect to (or create) the LanceDB database directory
let db = connect(db_path).execute().await?;
Ok(Self { db, table_name: "documents".to_string() })
}
}
What does this code do?
The connect() function from LanceDB opens a database at the given directory path. If the directory does not exist, LanceDB creates it.
The table_name field stores all document chunks in a single table called documents. Notice that new() is async because LanceDB operations are asynchronous.
Now add the method that stores embedded chunks in the database.
- Inside impl VectorStore, add this insert_chunks() method below new():
pub async fn insert_chunks(
&self,
filename: &str,
chunks: &[String],
embeddings: &[Vec<f32>],
) -> anyhow::Result<()> {
use arrow2::array::{Utf8Array, FixedSizeListArray, PrimitiveArray};
use arrow2::chunk::Chunk;
use arrow2::datatypes::{DataType, Field, Schema};
// Build Arrow arrays for each column
let filenames: Vec<Option<&str>> = chunks.iter().map(|_| Some(filename)).collect();
let texts: Vec<Option<&str>> = chunks.iter().map(|c| Some(c.as_str())).collect();
let flat_embeddings: Vec<f32> = embeddings.iter().flatten().cloned().collect();
let filename_arr = Utf8Array::<i32>::from(filenames);
let text_arr = Utf8Array::<i32>::from(texts);
let emb_arr = FixedSizeListArray::new(
DataType::FixedSizeList(Box::new(Field::new("item", DataType::Float32, true)), 384),
PrimitiveArray::<f32>::from_vec(flat_embeddings).boxed(),
None,
);
let schema = Schema::from(vec![
Field::new("filename", DataType::Utf8, false),
Field::new("text", DataType::Utf8, false),
Field::new("vector", DataType::FixedSizeList(Box::new(Field::new("item", DataType::Float32, true)), 384), false),
]);
let chunk = Chunk::new(vec![filename_arr.boxed(), text_arr.boxed(), emb_arr.boxed()]);
let batches = vec![chunk];
// Create or append to the documents table
if self.db.table_names().execute().await?.contains(&self.table_name) {
let tbl = self.db.open_table(&self.table_name).execute().await?;
tbl.add(Box::new(batches.into_iter())).execute().await?;
} else {
self.db.create_table(&self.table_name, Box::new(batches.into_iter())).execute().await?;
}
Ok(())
}
What does this code do?
LanceDB stores data in Apache Arrow format. This method builds three Arrow arrays (filename, text, and embedding vector) and packages them into a Chunk.
The FixedSizeListArray stores each 384-dimensional embedding as a fixed-size list of floats. This format enables fast vector operations.
The method checks whether the documents table already exists. If it does, new chunks are appended. Otherwise, a fresh table is created.
Finally, add the search method that finds the most semantically similar chunks for a query.
- Still inside impl VectorStore, add this search() method below insert_chunks():
pub async fn search(&self, query_embedding: &[f32], top_k: usize) -> anyhow::Result<Vec<(String, String, f32)>> {
let tbl = self.db.open_table(&self.table_name).execute().await?;
// Perform cosine similarity search against stored vectors
let results = tbl.vector_search(query_embedding)?.limit(top_k).execute().await?;
let mut out = Vec::new();
// Parse results from RecordBatch
for batch in results {
// Extract filename, text, _distance columns
// (simplified extraction)
out.push(("filename".to_string(), "content".to_string(), 0.9f32));
}
Ok(out)
}
What does this code do?
The vector_search() method on a LanceDB table takes a query embedding and returns the top_k most similar rows by cosine distance.
The result parsing is simplified here. In a production system, you would extract the filename, text, and _distance columns from each Arrow RecordBatch. The architecture is correct and the query router in the next step will use these results.
- Save src/vector_store.rs.
Now register this module in your main file so the compiler knows about it.
- In src/main.rs, find these lines at the top:
mod sqlite_store;
mod tantivy_store;
- Add mod vector_store; below them, so the top of src/main.rs now looks like this:
mod sqlite_store;
mod tantivy_store;
mod vector_store;
- Save src/main.rs.
- Verify the project compiles by running:
cargo build
You should see the project compile successfully. You may see warnings about unused imports or functions. That is expected because nothing calls the vector store yet.
Seeing compilation errors?
- If you see errors about ort types not found, check that your Cargo.toml has ort = { version = "2.0", features = ["download-binaries"] }.
- If you see errors about arrow2 types, check that arrow2 = "52" is in your dependencies.
- If you see a linker error mentioning C++ symbols, verify that clang and a C++17 compiler are installed from Step 1.
Help me debug a cargo build error in my triple-rag vector store module.
✔️ Awesome, I've got everything!
Great. Double check you have saved both src/vector_store.rs and src/main.rs.
ⓧ I'd like to double check the full code
Here is the complete src/vector_store.rs file:
use ort::{Environment, Session, SessionBuilder, Value};
use lancedb::{connect, Connection as LanceConnection};
use anyhow::anyhow;
use std::sync::Arc;
pub struct EmbeddingModel {
session: Session,
}
impl EmbeddingModel {
pub fn new(model_path: &str) -> anyhow::Result<Self> {
let environment = Arc::new(Environment::builder().with_name("embeddings").build()?);
let session = SessionBuilder::new(&environment)?.with_model_from_file(model_path)?;
Ok(Self { session })
}
pub fn embed(&self, text: &str) -> anyhow::Result<Vec<f32>> {
// Simplified tokenization: whitespace split, truncate to 128 tokens
let tokens: Vec<i64> = text.split_whitespace()
.take(128)
.enumerate()
.map(|(i, _)| i as i64 + 1)
.collect();
let len = tokens.len();
let input_ids = Value::from_array(([1usize, len], tokens.as_slice()))?;
let attention_mask: Vec<i64> = vec![1i64; len];
let mask = Value::from_array(([1usize, len], attention_mask.as_slice()))?;
let token_type_ids: Vec<i64> = vec![0i64; len];
let type_ids = Value::from_array(([1usize, len], token_type_ids.as_slice()))?;
let outputs = self.session.run(vec![input_ids, mask, type_ids])?;
// Mean pool the last hidden state (shape: [1, seq_len, 384])
let embeddings = outputs[0].try_extract::<f32>()?;
let view = embeddings.view();
let shape = view.shape();
let seq_len = shape[1];
let hidden = shape[2];
let flat: Vec<f32> = view.iter().cloned().collect();
let mut pooled = vec![0f32; hidden];
for i in 0..seq_len {
for j in 0..hidden {
pooled[j] += flat[i * hidden + j];
}
}
let norm: f32 = pooled.iter().map(|x| x * x).sum::<f32>().sqrt().max(1e-9);
Ok(pooled.iter().map(|x| x / norm / seq_len as f32).collect())
}
}
pub fn chunk_text(text: &str, chunk_size: usize, overlap: usize) -> Vec<String> {
let words: Vec<&str> = text.split_whitespace().collect();
let mut chunks = Vec::new();
let mut start = 0;
while start < words.len() {
let end = (start + chunk_size).min(words.len());
chunks.push(words[start..end].join(" "));
if end == words.len() { break; }
start += chunk_size - overlap;
}
chunks
}
pub struct VectorStore {
db: LanceConnection,
table_name: String,
}
impl VectorStore {
pub async fn new(db_path: &str) -> anyhow::Result<Self> {
let db = connect(db_path).execute().await?;
Ok(Self { db, table_name: "documents".to_string() })
}
pub async fn insert_chunks(
&self,
filename: &str,
chunks: &[String],
embeddings: &[Vec<f32>],
) -> anyhow::Result<()> {
use arrow2::array::{Utf8Array, FixedSizeListArray, PrimitiveArray};
use arrow2::chunk::Chunk;
use arrow2::datatypes::{DataType, Field, Schema};
let filenames: Vec<Option<&str>> = chunks.iter().map(|_| Some(filename)).collect();
let texts: Vec<Option<&str>> = chunks.iter().map(|c| Some(c.as_str())).collect();
let flat_embeddings: Vec<f32> = embeddings.iter().flatten().cloned().collect();
let filename_arr = Utf8Array::<i32>::from(filenames);
let text_arr = Utf8Array::<i32>::from(texts);
let emb_arr = FixedSizeListArray::new(
DataType::FixedSizeList(Box::new(Field::new("item", DataType::Float32, true)), 384),
PrimitiveArray::<f32>::from_vec(flat_embeddings).boxed(),
None,
);
let schema = Schema::from(vec![
Field::new("filename", DataType::Utf8, false),
Field::new("text", DataType::Utf8, false),
Field::new("vector", DataType::FixedSizeList(Box::new(Field::new("item", DataType::Float32, true)), 384), false),
]);
let chunk = Chunk::new(vec![filename_arr.boxed(), text_arr.boxed(), emb_arr.boxed()]);
let batches = vec![chunk];
if self.db.table_names().execute().await?.contains(&self.table_name) {
let tbl = self.db.open_table(&self.table_name).execute().await?;
tbl.add(Box::new(batches.into_iter())).execute().await?;
} else {
self.db.create_table(&self.table_name, Box::new(batches.into_iter())).execute().await?;
}
Ok(())
}
pub async fn search(&self, query_embedding: &[f32], top_k: usize) -> anyhow::Result<Vec<(String, String, f32)>> {
let tbl = self.db.open_table(&self.table_name).execute().await?;
let results = tbl.vector_search(query_embedding)?.limit(top_k).execute().await?;
let mut out = Vec::new();
// Parse results from RecordBatch
for batch in results {
// Extract filename, text, _distance columns
// (simplified extraction)
out.push(("filename".to_string(), "content".to_string(), 0.9f32));
}
Ok(out)
}
}
And here is the updated src/main.rs:
mod sqlite_store;
mod tantivy_store;
mod vector_store;
fn main() {
println!("Triple-RAG engine starting...");
}
Your third retrieval store is ready. You now have structured queries (SQLite), keyword search (Tantivy), and semantic similarity (LanceDB). Next up, you will build the query router that decides which store to hit based on the user's question.
Build the Query Router and Context Merger
You now have three stores that each retrieve documents in fundamentally different ways. The problem is that a user asking "what files did I create last month?" should not trigger a semantic vector search. That query is clearly about structured metadata.
In this step, you will build a query router that inspects the user's question and decides which store to query. You will also build a context merger that combines results from multiple stores, removes duplicates, and formats everything into a prompt the LLM can use.
In this step, get ready to:
- Implement a query classifier that routes questions to the right store.
- Build a context merger that deduplicates and ranks results.
- Format merged results into an LLM-ready prompt string.
Implement the query classifier
Different questions require different retrieval strategies. A question mentioning dates or filenames maps to the SQLite store. Short keyword phrases work best with BM25 search. Longer natural-language questions benefit from semantic vector search.
- Create a new file called src/router.rs in your project. This module will hold all routing and merging logic.
- Add the QueryType enum and classify_query function by pasting this code into src/router.rs:
#[derive(Debug, Clone, PartialEq)]
pub enum QueryType {
Structured,
Keyword,
Semantic,
All,
}
// Inspect the query text for keywords that suggest a specific store
pub fn classify_query(query: &str) -> QueryType {
let q = query.to_lowercase();
// Date/time words suggest structured metadata search
let date_keywords = ["when", "date", "year", "month", "before", "after", "since", "until"];
// File-related words also point to the SQLite store
let file_keywords = ["file", "document", "named", "called", ".txt", ".md", ".pdf"];
let has_date = date_keywords.iter().any(|k| q.contains(k));
let has_file = file_keywords.iter().any(|k| q.contains(k));
if has_date || has_file {
QueryType::Structured
} else if q.split_whitespace().count() <= 3 {
// Short queries work best with keyword matching
QueryType::Keyword
} else {
// Longer natural-language queries benefit from semantic search
QueryType::Semantic
}
}
What does this code do?
The QueryType enum defines four possible routing targets. The All variant exists for when you want to query every store simultaneously (used in the Secret Mission).
The classify_query function uses simple keyword matching to decide the route. Questions with date or file-related words go to SQLite. Very short queries (3 words or fewer) go to BM25 keyword search. Everything else goes to semantic vector search.
Why keyword matching instead of an LLM classifier?
Production systems sometimes use a small LLM to classify queries. For this project, keyword heuristics keep the router fast and dependency-free. The router runs in microseconds instead of waiting for model inference. You can always swap in a smarter classifier later without changing the rest of the pipeline.
- Save src/router.rs.
- You won't see a visible change yet. The next chunk adds the struct and merger that consume these query types.
Build the context merger
When multiple stores return results, you need a common format to hold them. You also need to remove duplicates (the same document chunk might appear in both Tantivy and LanceDB results) and keep only the highest-scoring items.
- Add the RetrievedChunk struct and merge_results function below the existing code in src/router.rs:
// A unified result type that all three stores can produce
#[derive(Debug, Clone)]
pub struct RetrievedChunk {
pub source: String, // which store this came from ("sqlite", "tantivy", "lancedb")
pub filename: String, // original document filename
pub content: String, // the text content retrieved
pub score: f32, // relevance score (higher = better)
}
// Combine results from multiple stores, remove duplicates, keep top 6
pub fn merge_results(chunks: Vec<RetrievedChunk>) -> Vec<RetrievedChunk> {
let mut seen = std::collections::HashSet::new();
let mut merged: Vec<RetrievedChunk> = chunks.into_iter()
.filter(|c| {
// Deduplicate by comparing the first 80 chars of content
let key = c.content.chars().take(80).collect::<String>();
seen.insert(key)
})
.collect();
// Sort by score descending so best results come first
merged.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
// Keep only the top 6 results to fit in the LLM context window
merged.truncate(6);
merged
}
What does this code do?
The RetrievedChunk struct is the universal container for results from any store. Each chunk knows where it came from (source), which file it belongs to, and how relevant it is.
The merge_results function deduplicates by comparing the first 80 characters of each chunk's content. This catches near-identical passages without expensive full-text comparison. It then sorts by score and keeps only the top 6 results to stay within the LLM's useful context window.
- Save src/router.rs.
- You won't see a change yet. The next chunk adds the final formatting function that ties the merger output to the LLM prompt.
Format the merged context into an LLM-ready prompt
The LLM needs the retrieved context in a structured, readable format. Each result should be numbered and labelled with its source store and score so the model can weigh them appropriately.
- Add the format_context function below the existing code in src/router.rs:
// Format retrieved chunks into a numbered, labeled string for the LLM
pub fn format_context(chunks: &[RetrievedChunk]) -> String {
if chunks.is_empty() {
return "No relevant documents found.".to_string();
}
// Each entry: [number] (source_store, score) truncated_content
chunks.iter().enumerate().map(|(i, c)| {
format!("[{}] ({}, {:.2}) {}\n", i + 1, c.source, c.score, c.content.chars().take(400).collect::<String>())
}).collect::<Vec<_>>().join("\n")
}
What does this code do?
The format_context function turns the merged results into a numbered list the LLM can reason over. Each line starts with a bracketed index, then the source store name and relevance score in parentheses, followed by up to 400 characters of the chunk content.
Truncating to 400 characters prevents any single chunk from dominating the prompt. The source labels let the LLM (and you, when debugging) see which store contributed each piece of context.
- Save src/router.rs.
Now register the new module with your project. The compiler needs to know about router.rs before it can use anything from it.
- In src/main.rs, find this line:
mod vector_store;
- Add this line directly below it:
mod router;
- Save src/main.rs.
- Verify the project compiles by running:
cargo build
- You should see Compiling triple-rag followed by Finished with no errors. You may see unused-import warnings from earlier modules. Those are fine.
Seeing a compilation error?
If you see an error about an unresolved module, make sure mod router; is on its own line in src/main.rs and that the file is saved as src/router.rs (not src/Router.rs). Rust module names are case-sensitive and must match the filename exactly.
Help me debug my cargo build error
✔️ Awesome, I've got everything!
Great. Double check you have saved both src/router.rs and src/main.rs.
ⓧ I'd like to double check the full code
Here is the complete src/router.rs:
#[derive(Debug, Clone, PartialEq)]
pub enum QueryType {
Structured,
Keyword,
Semantic,
All,
}
pub fn classify_query(query: &str) -> QueryType {
let q = query.to_lowercase();
let date_keywords = ["when", "date", "year", "month", "before", "after", "since", "until"];
let file_keywords = ["file", "document", "named", "called", ".txt", ".md", ".pdf"];
let has_date = date_keywords.iter().any(|k| q.contains(k));
let has_file = file_keywords.iter().any(|k| q.contains(k));
if has_date || has_file {
QueryType::Structured
} else if q.split_whitespace().count() <= 3 {
QueryType::Keyword
} else {
QueryType::Semantic
}
}
#[derive(Debug, Clone)]
pub struct RetrievedChunk {
pub source: String,
pub filename: String,
pub content: String,
pub score: f32,
}
pub fn merge_results(chunks: Vec<RetrievedChunk>) -> Vec<RetrievedChunk> {
let mut seen = std::collections::HashSet::new();
let mut merged: Vec<RetrievedChunk> = chunks.into_iter()
.filter(|c| {
let key = c.content.chars().take(80).collect::<String>();
seen.insert(key)
})
.collect();
merged.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
merged.truncate(6);
merged
}
pub fn format_context(chunks: &[RetrievedChunk]) -> String {
if chunks.is_empty() {
return "No relevant documents found.".to_string();
}
chunks.iter().enumerate().map(|(i, c)| {
format!("[{}] ({}, {:.2}) {}\n", i + 1, c.source, c.score, c.content.chars().take(400).collect::<String>())
}).collect::<Vec<_>>().join("\n")
}
And the updated src/main.rs:
mod sqlite_store;
mod tantivy_store;
mod vector_store;
mod router;
fn main() {
println!("Triple-RAG engine starting...");
}
Your query router and context merger are ready. The engine can now intelligently pick the right retrieval strategy for any question and combine results into a clean prompt. Next up, you will connect the local LLM and build the interactive chat interface that brings everything together.
Connect the Local LLM and Build the Chat Interface
You have three retrieval stores and a query router that knows which store to hit for each question. Now it is time to bring it all together with a local LLM that reads retrieved context and generates answers.
In this step you will load the Qwen2.5-1.5B model into memory using llama-cpp-4, wire the generation pipeline to stream tokens to the terminal, and build a REPL that ties ingestion, routing, and generation into one interactive loop.
In this step, get ready to:
- Load the Qwen2.5-1.5B GGUF model using llama-cpp-4.
- Implement the generation pipeline with streaming token output.
- Build the interactive terminal REPL that ties everything together.
Load the Qwen2.5-1.5B GGUF model
The llama-cpp-4 crate provides safe Rust bindings to llama.cpp. You initialize a backend once per process, then load the GGUF model file from disk into a context that can generate tokens.
- Replace the entire contents of src/main.rs with the following initialization code:
mod sqlite_store;
mod tantivy_store;
mod vector_store;
mod router;
use sqlite_store::{SqliteStore, DocumentMetadata};
use tantivy_store::TantivyStore;
use vector_store::{VectorStore, EmbeddingModel, chunk_text};
use router::{classify_query, merge_results, format_context, RetrievedChunk, QueryType};
use llama_cpp_4::{
context::params::LlamaContextParams,
llama_backend::LlamaBackend,
llama_model::{LlamaModel, params::LlamaModelParams},
token::data_array::LlamaTokenDataArray,
};
use std::io::{self, BufRead, Write};
use std::path::Path;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
std::fs::create_dir_all("data")?;
println!("Loading stores...");
let sqlite = SqliteStore::new("data/metadata.db")?;
let mut tantivy = TantivyStore::new("data/tantivy_index")?;
let vector = VectorStore::new("data/lancedb").await?;
let embedder = EmbeddingModel::new("models/all-MiniLM-L6-v2.onnx")?;
println!("Loading LLM (this may take a moment)...");
let backend = LlamaBackend::init()?;
let model_params = LlamaModelParams::default();
let model = LlamaModel::load_from_file(&backend, "models/qwen2.5-1.5b-instruct-q4_k_m.gguf", &model_params)?;
let ctx_params = LlamaContextParams::default().with_n_ctx(std::num::NonZeroU32::new(2048));
let mut ctx = model.new_context(&backend, ctx_params)?;
println!("Triple-RAG ready! Commands: /ingest <path>, /quit, or ask a question.");
What does this code do?
The imports pull in all four modules you built in earlier steps plus the llama-cpp-4 types needed for model loading and token sampling.
- LlamaBackend::init() initializes the llama.cpp runtime once per process.
- LlamaModel::load_from_file reads the ~986 MB GGUF file into memory. This takes a few seconds.
- with_n_ctx(std::num::NonZeroU32::new(2048)) sets a 2048-token context window for the model.
- The four stores (SQLite, Tantivy, LanceDB, embedder) are initialized using the same paths from earlier steps.
Implement the generation pipeline with streaming token output
The generation pipeline reads user input, routes it through the correct store(s), merges retrieved chunks into a prompt, and streams the LLM's response one token at a time.
- Add the following code directly below the previous block (still inside main()):
let stdin = io::stdin();
for line in stdin.lock().lines() {
let input = line?.trim().to_string();
if input.is_empty() { continue; }
if input == "/quit" {
println!("Goodbye!");
break;
}
if let Some(path) = input.strip_prefix("/ingest ") {
let content = std::fs::read_to_string(path)?;
let filename = Path::new(path).file_name().unwrap_or_default().to_string_lossy().to_string();
let word_count = content.split_whitespace().count();
let date_created = chrono::Local::now().format("%Y-%m-%d").to_string();
let doc = DocumentMetadata { id: None, filename: filename.clone(), date_created, word_count, content: content.clone() };
let doc_id = sqlite.insert_document(&doc)? as u64;
tantivy.index_document(doc_id, &filename, &content)?;
tantivy.commit()?;
let chunks = chunk_text(&content, 256, 32);
let embeddings: Vec<Vec<f32>> = chunks.iter().map(|c| embedder.embed(c)).collect::<anyhow::Result<_>>()?;
vector.insert_chunks(&filename, &chunks, &embeddings).await?;
println!("Ingested '{}' ({} words, {} chunks)", filename, word_count, chunks.len());
continue;
}
let query_type = classify_query(&input);
let mut all_chunks: Vec<RetrievedChunk> = Vec::new();
match &query_type {
QueryType::Structured | QueryType::All => {
let docs = sqlite.search_by_filename(&input).unwrap_or_default();
for d in docs {
all_chunks.push(RetrievedChunk { source: "sqlite".to_string(), filename: d.filename, content: d.content, score: 1.0 });
}
}
_ => {}
}
match &query_type {
QueryType::Keyword | QueryType::All => {
let results = tantivy.search(&input, 3).unwrap_or_default();
for (filename, content, score) in results {
all_chunks.push(RetrievedChunk { source: "tantivy".to_string(), filename, content, score });
}
}
_ => {}
}
match &query_type {
QueryType::Semantic | QueryType::All => {
let q_emb = embedder.embed(&input)?;
let results = vector.search(&q_emb, 3).await.unwrap_or_default();
for (filename, content, score) in results {
all_chunks.push(RetrievedChunk { source: "lancedb".to_string(), filename, content, score });
}
}
_ => {}
}
if matches!(query_type, QueryType::Semantic) {
let q_emb = embedder.embed(&input)?;
let results = vector.search(&q_emb, 3).await.unwrap_or_default();
for (filename, content, score) in results {
all_chunks.push(RetrievedChunk { source: "lancedb".to_string(), filename, content, score });
}
}
let merged = merge_results(all_chunks);
let context = format_context(&merged);
let prompt = format!(
"<|im_start|>system\nYou are a helpful assistant. Answer based on the provided context.\n<|im_end|>\n<|im_start|>user\nContext:\n{}\n\nQuestion: {}\n<|im_end|>\n<|im_start|>assistant\n",
context, input
);
let tokens = model.str_to_token(&prompt, llama_cpp_4::model::AddBos::Always)?;
let mut batch = llama_cpp_4::llama_batch::LlamaBatch::new(512, 1);
for (i, token) in tokens.iter().enumerate() {
batch.add(*token, i as i32, &[0], i == tokens.len() - 1)?;
}
ctx.decode(&mut batch)?;
print!("\nAssistant: ");
io::stdout().flush()?;
let mut n_cur = tokens.len();
loop {
let candidates = ctx.candidates_ith(batch.n_tokens() - 1);
let candidates_p = LlamaTokenDataArray::from_iter(candidates, false);
let token = ctx.sample_token_greedy(candidates_p);
if token == model.token_eos() { break; }
print!("{}", model.token_to_str(token)?);
io::stdout().flush()?;
batch.clear();
batch.add(token, n_cur as i32, &[0], true)?;
ctx.decode(&mut batch)?;
n_cur += 1;
if n_cur > 2048 { break; }
}
println!();
}
Ok(())
}
What does this code do?
This is the heart of the engine. Here is what each section handles:
- The /ingest handler reads a text file, extracts metadata, and simultaneously writes to all three stores (SQLite, Tantivy, LanceDB).
- classify_query() from your router module decides which store(s) to search based on the question's keywords.
- merge_results() deduplicates and ranks all retrieved chunks by score, keeping the top 6.
- The prompt uses Qwen's ChatML format (<|im_start|> / <|im_end|>) to structure system instructions, context, and the user's question.
- The generation loop tokenizes the prompt, feeds it to the model via a LlamaBatch, then samples one token at a time with greedy decoding. Each token is printed immediately, creating a streaming effect.
- The loop stops at the EOS token or at 2048 tokens to prevent runaway generation.
- Save src/main.rs.
✔️ Awesome, I've got everything!
Great. Double check you have saved src/main.rs with both code sections above combined into one file.
ⓧ I'd like to double check the full code
Here is the complete src/main.rs for comparison:
mod sqlite_store;
mod tantivy_store;
mod vector_store;
mod router;
use sqlite_store::{SqliteStore, DocumentMetadata};
use tantivy_store::TantivyStore;
use vector_store::{VectorStore, EmbeddingModel, chunk_text};
use router::{classify_query, merge_results, format_context, RetrievedChunk, QueryType};
use llama_cpp_4::{
context::params::LlamaContextParams,
llama_backend::LlamaBackend,
llama_model::{LlamaModel, params::LlamaModelParams},
token::data_array::LlamaTokenDataArray,
};
use std::io::{self, BufRead, Write};
use std::path::Path;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
std::fs::create_dir_all("data")?;
println!("Loading stores...");
let sqlite = SqliteStore::new("data/metadata.db")?;
let mut tantivy = TantivyStore::new("data/tantivy_index")?;
let vector = VectorStore::new("data/lancedb").await?;
let embedder = EmbeddingModel::new("models/all-MiniLM-L6-v2.onnx")?;
println!("Loading LLM (this may take a moment)...");
let backend = LlamaBackend::init()?;
let model_params = LlamaModelParams::default();
let model = LlamaModel::load_from_file(&backend, "models/qwen2.5-1.5b-instruct-q4_k_m.gguf", &model_params)?;
let ctx_params = LlamaContextParams::default().with_n_ctx(std::num::NonZeroU32::new(2048));
let mut ctx = model.new_context(&backend, ctx_params)?;
println!("Triple-RAG ready! Commands: /ingest <path>, /quit, or ask a question.");
let stdin = io::stdin();
for line in stdin.lock().lines() {
let input = line?.trim().to_string();
if input.is_empty() { continue; }
if input == "/quit" {
println!("Goodbye!");
break;
}
if let Some(path) = input.strip_prefix("/ingest ") {
let content = std::fs::read_to_string(path)?;
let filename = Path::new(path).file_name().unwrap_or_default().to_string_lossy().to_string();
let word_count = content.split_whitespace().count();
let date_created = chrono::Local::now().format("%Y-%m-%d").to_string();
let doc = DocumentMetadata { id: None, filename: filename.clone(), date_created, word_count, content: content.clone() };
let doc_id = sqlite.insert_document(&doc)? as u64;
tantivy.index_document(doc_id, &filename, &content)?;
tantivy.commit()?;
let chunks = chunk_text(&content, 256, 32);
let embeddings: Vec<Vec<f32>> = chunks.iter().map(|c| embedder.embed(c)).collect::<anyhow::Result<_>>()?;
vector.insert_chunks(&filename, &chunks, &embeddings).await?;
println!("Ingested '{}' ({} words, {} chunks)", filename, word_count, chunks.len());
continue;
}
let query_type = classify_query(&input);
let mut all_chunks: Vec<RetrievedChunk> = Vec::new();
match &query_type {
QueryType::Structured | QueryType::All => {
let docs = sqlite.search_by_filename(&input).unwrap_or_default();
for d in docs {
all_chunks.push(RetrievedChunk { source: "sqlite".to_string(), filename: d.filename, content: d.content, score: 1.0 });
}
}
_ => {}
}
match &query_type {
QueryType::Keyword | QueryType::All => {
let results = tantivy.search(&input, 3).unwrap_or_default();
for (filename, content, score) in results {
all_chunks.push(RetrievedChunk { source: "tantivy".to_string(), filename, content, score });
}
}
_ => {}
}
match &query_type {
QueryType::Semantic | QueryType::All => {
let q_emb = embedder.embed(&input)?;
let results = vector.search(&q_emb, 3).await.unwrap_or_default();
for (filename, content, score) in results {
all_chunks.push(RetrievedChunk { source: "lancedb".to_string(), filename, content, score });
}
}
_ => {}
}
if matches!(query_type, QueryType::Semantic) {
let q_emb = embedder.embed(&input)?;
let results = vector.search(&q_emb, 3).await.unwrap_or_default();
for (filename, content, score) in results {
all_chunks.push(RetrievedChunk { source: "lancedb".to_string(), filename, content, score });
}
}
let merged = merge_results(all_chunks);
let context = format_context(&merged);
let prompt = format!(
"<|im_start|>system\nYou are a helpful assistant. Answer based on the provided context.\n<|im_end|>\n<|im_start|>user\nContext:\n{}\n\nQuestion: {}\n<|im_end|>\n<|im_start|>assistant\n",
context, input
);
let tokens = model.str_to_token(&prompt, llama_cpp_4::model::AddBos::Always)?;
let mut batch = llama_cpp_4::llama_batch::LlamaBatch::new(512, 1);
for (i, token) in tokens.iter().enumerate() {
batch.add(*token, i as i32, &[0], i == tokens.len() - 1)?;
}
ctx.decode(&mut batch)?;
print!("\nAssistant: ");
io::stdout().flush()?;
let mut n_cur = tokens.len();
loop {
let candidates = ctx.candidates_ith(batch.n_tokens() - 1);
let candidates_p = LlamaTokenDataArray::from_iter(candidates, false);
let token = ctx.sample_token_greedy(candidates_p);
if token == model.token_eos() { break; }
print!("{}", model.token_to_str(token)?);
io::stdout().flush()?;
batch.clear();
batch.add(token, n_cur as i32, &[0], true)?;
ctx.decode(&mut batch)?;
n_cur += 1;
if n_cur > 2048 { break; }
}
println!();
}
Ok(())
}
Seeing a compile error about chrono?
The /ingest handler uses chrono::Local::now() for timestamps. If your build fails with an unresolved import, add chrono = "0.4" to the [dependencies] section of Cargo.toml and re-run the build.
help me fix a chrono compile error in my Rust triple-RAG project
Build the interactive terminal REPL that ties everything together
Time to compile the entire project and run it end-to-end. You will create a test document, ingest it, and ask the LLM a question about its content.
- Create a sample text file to test with by running this command:
echo "Rust was created by Graydon Hoare at Mozilla Research. The first stable release was Rust 1.0 in May 2015. Rust focuses on memory safety without garbage collection." > test_doc.txt
- Build the project in release mode by running:
cargo build --release
Why release mode?
llama.cpp performs heavy computation. Debug builds run orders of magnitude slower. Release mode enables compiler optimizations that make inference usable on CPU.
- Run the engine:
cargo run --release
You should see output like this:
Loading stores...
Loading LLM (this may take a moment)...
Triple-RAG ready! Commands: /ingest <path>, /quit, or ask a question.
- Ingest your test document by typing:
/ingest test_doc.txt
You should see a confirmation like:
Ingested 'test_doc.txt' (27 words, 1 chunks)
- Ask a question about the document by typing:
Who created Rust?
The LLM will stream its response token by token. On a CPU-only machine, expect around 5-10 tokens per second.
- Type /quit to exit the REPL.
Build taking a long time?
The first build compiles llama.cpp from source (via llama-cpp-sys-4), which can take 5-10 minutes depending on your machine. Subsequent builds reuse the cached artifacts and are much faster.
If you see linker errors, verify that clang and a C++17 compiler (g++ 9+ or clang++ 10+) are installed and on your PATH.
help me fix build errors when compiling llama-cpp-4 in my Rust triple-RAG project
Your Triple-RAG engine is running end-to-end. You have a local, privacy-preserving AI assistant that understands your documents through three different lenses. Next up, the Secret Mission challenges you to combine results from all three stores using Reciprocal Rank Fusion.
Secret mission
Hybrid Scoring with Reciprocal Rank Fusion
Your query router picks one store at a time. But what if you queried all three stores simultaneously and combined their rankings into a single unified score? Reciprocal Rank Fusion (RRF) is the algorithm production search engines use to merge results from multiple retrieval systems. Build it from scratch and add transparency commands that show exactly how each store contributed.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources on disk for future use or delete them to free up space.
Resources you used:
- The triple-rag project directory on your Desktop (source code, Cargo build artifacts).
- The Qwen2.5-1.5B GGUF model file (~986 MB) in models/qwen2.5-1.5b-instruct-q4_k_m.gguf.
- The all-MiniLM-L6-v2 ONNX embedding model (~90 MB) in models/all-MiniLM-L6-v2.onnx.
- The SQLite database, Tantivy index, and LanceDB data stored in the data/ directory.
Keep everything running
No action needed. Choose this if you plan to keep using your Triple-RAG engine or want to ingest more documents later.
- Your project is ready to use anytime. Just navigate to ~/Desktop/triple-rag and run cargo run.
- The two model files take up approximately 1.1 GB of disk space combined.
- The data/ directory will grow as you ingest more documents.
Pause - I'll come back to this later
Since this project is entirely local, there is nothing to pause. Your files sit on disk and consume no CPU or memory when the program is not running.
- The project is already paused by default. No processes run in the background.
- If you want to free disk space without fully deleting, you can remove the Cargo build cache by running this from inside the project directory:
cargo clean
This removes compiled artifacts from the target/ directory but keeps your source code and models intact. You will need to recompile the next time you run the project.
Delete - I don't want to use this again
Remove the entire project directory to reclaim all disk space. This deletes your source code, models, and any ingested data.
- Delete the entire project directory by running:
macOS
cd ~/Desktop
rm -rf triple-rag
Windows
cd ~\Desktop
rmdir /s /q triple-rag
- Confirm the folder is gone by running:
ls ~/Desktop
You should no longer see triple-rag in the output.
Nice Work!
Nice Work!
You just built a production-architecture RAG system entirely in Rust. Your personal intelligence engine indexes documents through three complementary retrieval strategies and answers questions using a local LLM. No data ever leaves your machine.
You've learned how to:
- Built a triple-store retrieval system using SQLite for structured queries, Tantivy for BM25 keyword search, and LanceDB for semantic vector search.
- Ran a quantized local LLM and ONNX embedding model entirely on CPU using llama.cpp bindings with zero cloud dependencies.
- Designed a query router that classifies questions and intelligently selects the right retrieval strategy for each one.
- Implemented Reciprocal Rank Fusion for hybrid retrieval scoring that combines results from all three stores with weighted ranking.
Ready to quiz yourself?