AI Agent Pipeline in Rust with Rig
Build a multi-agent research pipeline in Rust using Rig and Candle embeddings.
Introduction
30 Second Summary
Modern AI tools rarely rely on a single model answering a single question. Behind the scenes, they break problems into pieces, gather relevant information from specialized sources, and combine everything into a polished answer through a chain of steps.
In this project, you will build a multi-agent research pipeline in Rust that decomposes questions into sub-topics, retrieves relevant context from a local knowledge base, and synthesizes comprehensive answers.
What You'll Build
A Rust application that orchestrates specialized AI agents through Rig's pipeline API, using Candle-powered local embeddings for document retrieval and OpenAI models for reasoning.
By the end of this project, you'll have:
- Custom tools (Summarize and SearchKnowledgeBase) implementing Rig's Tool trait for agent-invocable actions.
- A local embedding system using Candle with the all-MiniLM-L6-v2 sentence-transformers model for vector search on CPU.
- A multi-stage pipeline chaining a research planner agent, a knowledge retrieval stage, and a synthesizer agent using Rig's pipeline::new() API.
- Secret Mission: A dynamic routing agent that classifies queries and branches the pipeline based on whether the input is factual or creative.
Are there any prerequisites?
You should be comfortable with basic Rust concepts like variables, functions, structs, traits, and async/await. You also need an OpenAI account with an API key for the LLM reasoning stages.
Before We Start
Before We Start...
Set Up Your Rust Project and Dependencies
To build a multi-agent research pipeline, you need a Rust project with the right dependencies wired up.
This step creates the project scaffold and pulls in two key frameworks: Rig for agent orchestration and Candle for local ML inference. Together they let you build LLM-powered pipelines entirely in Rust.
In this step, get ready to:
- Create a Rust project and configure its dependencies.
- Set your OpenAI API key as an environment variable.
- Create the module files that will hold your pipeline code.
Create the Rust project and configure dependencies
Every Rust project starts with Cargo, the Rust package manager. You will use it to scaffold a new binary project and declare the crates your pipeline needs.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create the project and move into it by running these commands:
cargo new research-pipeline
cd research-pipeline
What does this command do?
The cargo new command scaffolds a new Rust binary project. It creates a Cargo.toml manifest and a src/main.rs file with a hello-world program.
- Confirm the project was created by running:
ls
You should see Cargo.toml and a src directory in the output.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code (or Cursor) and press Enter to open it.
- Click File in the top menu bar.
- Click Open Folder.
- Select the research-pipeline folder on your Desktop, then click Open.
Now replace the generated Cargo.toml with the full dependency list your pipeline needs.
- Open Cargo.toml in the editor sidebar.
- Select all the existing content (Cmd+A on macOS or Ctrl+A on Windows) and replace it with the following:
[package]
name = "research-pipeline"
version = "0.1.0"
edition = "2021"
[dependencies]
rig-core = { version = "0.36.0", features = ["derive"] }
candle-core = "0.10.2"
candle-nn = "0.10.2"
candle-transformers = "0.10.2"
hf-hub = { version = "0.4.1", features = ["tokio"] }
tokenizers = "0.22.0"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
anyhow = "1"
thiserror = "2"
What are these dependencies for?
- rig-core provides the agent builder, pipeline composition, and tool-calling framework.
- candle-core, candle-nn, and candle-transformers power local BERT embeddings without Python.
- hf-hub downloads model weights from Hugging Face on first run.
- tokenizers handles text tokenization for the BERT model.
- tokio is the async runtime that Rig requires for all LLM calls.
- Save Cargo.toml.
Seeing red squiggles in Cargo.toml?
Your editor may flag unresolved crates until you run cargo build for the first time. You will do that in the next substep.
If you see a syntax error, double-check that each dependency line uses the exact format shown above. Missing quotes or brackets will cause parse failures.
Help me fix a Cargo.toml syntax error in my Rust project.
Set the OpenAI API key and verify the build
Rig reads your OpenAI credentials from an environment variable at runtime. Setting it now means the pipeline can authenticate when you run it later.
- Set your API key as an environment variable in the terminal.
macOS
- Run this in your terminal, replacing the placeholder with your actual key:
export OPENAI_API_KEY=your-api-key-here
Windows
- Run this in PowerShell, replacing the placeholder with your actual key:
$env:OPENAI_API_KEY="your-api-key-here"
Why an environment variable?
Storing API keys in environment variables keeps them out of your source code. If you accidentally commit code to Git, the key stays safe because it only lives in your terminal session.
- Verify the project compiles with all dependencies by running:
cargo build
This downloads and compiles all crates. It may take 2-5 minutes on the first run because Candle's ML libraries are large.
You should see output ending with Finished followed by a build profile and timing info.
Build failing with dependency errors?
Make sure all three Candle crates (candle-core, candle-nn, candle-transformers) use the same version 0.10.2. Mismatched versions cause trait conflicts.
If you see a network error, check your internet connection. Cargo needs to download crates from crates.io.
Help me debug a cargo build failure in my research-pipeline project.
Create the project file structure
Your pipeline will be split across four modules: one for tools, one for embeddings, one for pipeline orchestration, and the main entry point. Creating these files now gives you a clean skeleton to fill in over the next steps.
- Create the module files by running these commands from the research-pipeline directory:
macOS
touch src/tools.rs src/embeddings.rs src/pipeline.rs
Windows
New-Item -Path src/tools.rs -ItemType File
New-Item -Path src/embeddings.rs -ItemType File
New-Item -Path src/pipeline.rs -ItemType File
- Confirm the files exist by running:
ls src/
You should see embeddings.rs, main.rs, pipeline.rs, and tools.rs listed.
Why separate files?
Rust's module system maps files to modules. Each .rs file in src/ becomes a module you can import with mod in main.rs. This keeps the codebase organized as it grows.
Don't see all four files?
Check that you ran the file creation commands while inside the research-pipeline directory. If you are somewhere else, run cd ~/Desktop/research-pipeline first.
Help me create the module files for my Rust project.
✔️ Awesome, I've got everything!
Great. Double check you have saved Cargo.toml and that all four .rs files are visible in your editor's sidebar.
ⓧ I'd like to double check the full code
Here is the complete Cargo.toml you should have at this point:
[package]
name = "research-pipeline"
version = "0.1.0"
edition = "2021"
[dependencies]
rig-core = { version = "0.36.0", features = ["derive"] }
candle-core = "0.10.2"
candle-nn = "0.10.2"
candle-transformers = "0.10.2"
hf-hub = { version = "0.4.1", features = ["tokio"] }
tokenizers = "0.22.0"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
anyhow = "1"
thiserror = "2"
Your src/ directory should contain four files: main.rs (with the default hello-world code from cargo new), plus empty tools.rs, embeddings.rs, and pipeline.rs files.
Your project skeleton is ready. Next up, you will build custom tools that your AI agents can invoke autonomously.
Build Custom Tools for the Agents
Your project structure is ready. Now it's time to give your AI agents some capabilities beyond just generating text.
In Rig, agents gain superpowers through tools. A tool is a Rust struct that implements Rig's Tool trait, exposing a typed function that the LLM can call autonomously during a conversation.
In this step, get ready to:
- Build a Summarize tool that condenses text into fewer sentences.
- Build a SearchKnowledgeBase tool that queries a local vector store for relevant documents.
- Verify both tools compile successfully.
Implement the Summarize tool
The Summarize tool takes a block of text and returns only the first N sentences. This is a mock implementation using string manipulation, keeping the focus on the pipeline architecture rather than summarization logic.
- Open src/tools.rs in your editor and add the following code:
use anyhow::Result;
use rig::completion::ToolDefinition;
use rig::tool::Tool;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::sync::Arc;
use tokio::sync::RwLock;
use crate::embeddings::KnowledgeBase;
// ─── Summarize Tool ───────────────────────────────────────────────────────────
#[derive(Deserialize)]
pub struct SummarizeArgs {
pub text: String,
pub max_sentences: usize,
}
#[derive(Debug, thiserror::Error)]
#[error("Summarization error: {0}")]
pub struct SummarizeError(String);
#[derive(Deserialize, Serialize)]
pub struct Summarize;
impl Tool for Summarize {
const NAME: &'static str = "summarize";
type Error = SummarizeError;
type Args = SummarizeArgs;
type Output = String;
async fn definition(&self, _prompt: String) -> ToolDefinition {
ToolDefinition {
name: "summarize".to_string(),
description: "Summarize a block of text into a specified number of sentences".to_string(),
parameters: json!({
"type": "object",
"properties": {
"text": { "type": "string", "description": "The text to summarize" },
"max_sentences": { "type": "number", "description": "Maximum number of sentences in the summary" }
},
"required": ["text", "max_sentences"]
}),
}
}
async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
let sentences: Vec<&str> = args.text.split(". ").collect();
let summary: String = sentences.into_iter().take(args.max_sentences).collect::<Vec<&str>>().join(". ");
Ok(if summary.ends_with('.') { summary } else { format!("{}.", summary) })
}
}
What does this code do?
The imports bring in Rig's Tool trait and ToolDefinition struct, which are the building blocks for any tool an agent can call.
- SummarizeArgs defines the parameters the LLM must provide when calling this tool: the text to summarize and the maximum number of sentences.
- SummarizeError uses the thiserror crate to create a descriptive error type.
- The definition() method returns a JSON schema that tells the LLM what this tool does and what arguments it expects.
- The call() method splits text on ". " boundaries and takes only the first N sentences as a simple summarization strategy.
- Save src/tools.rs.
Seeing red squiggles in your editor?
Your editor may show errors on the use crate::embeddings::KnowledgeBase line. This is expected because src/embeddings.rs is still empty. You will resolve this in the next substep when you add a placeholder, and fully implement it in Step 3.
Why isn't my KnowledgeBase import resolving?
Implement the SearchKnowledgeBase tool
The second tool gives agents the ability to query your local knowledge base for relevant documents. This is the RAG retrieval mechanism that connects your agents to stored knowledge.
- In src/tools.rs, add the following code below the Summarize tool implementation:
// ─── SearchKnowledgeBase Tool ─────────────────────────────────────────────────
#[derive(Deserialize)]
pub struct SearchArgs {
pub query: String,
pub num_results: usize,
}
#[derive(Debug, thiserror::Error)]
#[error("Search error: {0}")]
pub struct SearchError(String);
#[derive(Serialize, Deserialize)]
pub struct SearchKnowledgeBase {
#[serde(skip)]
pub kb: Option<Arc<RwLock<KnowledgeBase>>>,
}
impl SearchKnowledgeBase {
pub fn new(kb: Arc<RwLock<KnowledgeBase>>) -> Self {
Self { kb: Some(kb) }
}
}
impl Tool for SearchKnowledgeBase {
const NAME: &'static str = "search_knowledge_base";
type Error = SearchError;
type Args = SearchArgs;
type Output = String;
async fn definition(&self, _prompt: String) -> ToolDefinition {
ToolDefinition {
name: "search_knowledge_base".to_string(),
description: "Search the local knowledge base for documents relevant to a query".to_string(),
parameters: json!({
"type": "object",
"properties": {
"query": { "type": "string", "description": "The search query" },
"num_results": { "type": "number", "description": "Number of results to return" }
},
"required": ["query", "num_results"]
}),
}
}
async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
let kb = self.kb.as_ref().ok_or_else(|| SearchError("Knowledge base not initialized".to_string()))?;
let kb_read = kb.read().await;
let results = kb_read.search(&args.query, args.num_results).await.map_err(|e| SearchError(e.to_string()))?;
Ok(results.join("\n\n---\n\n"))
}
}
What does this code do?
- SearchKnowledgeBase holds an Option<Arc<RwLock<KnowledgeBase>>> so it can be shared safely across async tasks. The #[serde(skip)] attribute excludes this field from serialization since it contains runtime state.
- The new() constructor wraps the knowledge base in Some, making it ready for use by the agent.
- In call(), the tool acquires a read lock on the knowledge base, runs a semantic search, and joins the results with separator lines.
- If the knowledge base is not initialized, the tool returns a descriptive SearchError rather than panicking.
- Save src/tools.rs.
Errors about Arc or RwLock?
Make sure your imports at the top of the file include use std::sync::Arc; and use tokio::sync::RwLock;. These are required for the shared ownership pattern used by SearchKnowledgeBase.
Help me fix my Arc or RwLock import errors.
✔️ Awesome, I've got everything!
Great. Double check you have saved src/tools.rs.
ⓧ I'd like to double check the full code
use anyhow::Result;
use rig::completion::ToolDefinition;
use rig::tool::Tool;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::sync::Arc;
use tokio::sync::RwLock;
use crate::embeddings::KnowledgeBase;
#[derive(Deserialize)]
pub struct SummarizeArgs {
pub text: String,
pub max_sentences: usize,
}
#[derive(Debug, thiserror::Error)]
#[error("Summarization error: {0}")]
pub struct SummarizeError(String);
#[derive(Deserialize, Serialize)]
pub struct Summarize;
impl Tool for Summarize {
const NAME: &'static str = "summarize";
type Error = SummarizeError;
type Args = SummarizeArgs;
type Output = String;
async fn definition(&self, _prompt: String) -> ToolDefinition {
ToolDefinition {
name: "summarize".to_string(),
description: "Summarize a block of text into a specified number of sentences".to_string(),
parameters: json!({
"type": "object",
"properties": {
"text": { "type": "string", "description": "The text to summarize" },
"max_sentences": { "type": "number", "description": "Maximum number of sentences in the summary" }
},
"required": ["text", "max_sentences"]
}),
}
}
async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
let sentences: Vec<&str> = args.text.split(". ").collect();
let summary: String = sentences.into_iter().take(args.max_sentences).collect::<Vec<&str>>().join(". ");
Ok(if summary.ends_with('.') { summary } else { format!("{}.", summary) })
}
}
#[derive(Deserialize)]
pub struct SearchArgs {
pub query: String,
pub num_results: usize,
}
#[derive(Debug, thiserror::Error)]
#[error("Search error: {0}")]
pub struct SearchError(String);
#[derive(Serialize, Deserialize)]
pub struct SearchKnowledgeBase {
#[serde(skip)]
pub kb: Option<Arc<RwLock<KnowledgeBase>>>,
}
impl SearchKnowledgeBase {
pub fn new(kb: Arc<RwLock<KnowledgeBase>>) -> Self {
Self { kb: Some(kb) }
}
}
impl Tool for SearchKnowledgeBase {
const NAME: &'static str = "search_knowledge_base";
type Error = SearchError;
type Args = SearchArgs;
type Output = String;
async fn definition(&self, _prompt: String) -> ToolDefinition {
ToolDefinition {
name: "search_knowledge_base".to_string(),
description: "Search the local knowledge base for documents relevant to a query".to_string(),
parameters: json!({
"type": "object",
"properties": {
"query": { "type": "string", "description": "The search query" },
"num_results": { "type": "number", "description": "Number of results to return" }
},
"required": ["query", "num_results"]
}),
}
}
async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
let kb = self.kb.as_ref().ok_or_else(|| SearchError("Knowledge base not initialized".to_string()))?;
let kb_read = kb.read().await;
let results = kb_read.search(&args.query, args.num_results).await.map_err(|e| SearchError(e.to_string()))?;
Ok(results.join("\n\n---\n\n"))
}
}
Verify both tools compile
Your tools.rs imports a KnowledgeBase type from embeddings.rs, which is still empty. You need a temporary placeholder so Rust can resolve the import and verify your tool code is correct.
- Open src/embeddings.rs and add this temporary placeholder:
// Temporary placeholder - will be fully implemented in Step 3
pub struct KnowledgeBase;
impl KnowledgeBase {
pub async fn search(&self, _query: &str, _num_results: usize) -> anyhow::Result<Vec<String>> {
Ok(vec![])
}
}
- Open src/main.rs and add this temporary entry point:
// Temporary main - will be fully implemented in Step 5
mod embeddings;
mod tools;
fn main() {
println!("Modules compile!");
}
Why add placeholders?
Rust compiles your entire project as a single unit. Even though you only wrote tools.rs, the compiler needs to resolve every import and module reference. These placeholders satisfy the compiler now and will be replaced with full implementations in later steps.
- Save both files, then verify everything compiles by running:
cargo build
You should see a successful compilation message with no errors. You may see warnings about unused imports, which is fine at this stage.
Seeing compilation errors?
- If you see an error about KnowledgeBase not found, make sure you saved the placeholder in src/embeddings.rs and that src/main.rs contains mod embeddings;.
- If you see a trait-related error, double check that rig-core in your Cargo.toml uses version 0.36.0 with the derive feature enabled.
- If the download takes a while on first build, that is normal. Cargo is fetching all dependencies.
Help me debug my cargo build error.
Both tools are ready. Next up, you will build the local embedding system with Candle that powers the SearchKnowledgeBase tool with real semantic search.
Set Up Local Embeddings with Candle
Your tools are built, but the SearchKnowledgeBase tool needs an actual knowledge base to search. That knowledge base needs embeddings to understand the meaning of documents and queries.
In this step, you will build a local embedding system using Candle and a sentence-transformers model from Hugging Face. This generates 384-dimensional vectors entirely on your CPU, with no external embedding API calls required.
In this step, get ready to:
- Create a LocalEmbedder struct that downloads and loads a BERT model from Hugging Face Hub.
- Implement an embed_text method that produces normalized embedding vectors.
- Build a KnowledgeBase with pre-loaded sample documents and semantic search.
Create the LocalEmbedder struct
The LocalEmbedder wraps a BERT model that converts text into numerical vectors. It downloads the all-MiniLM-L6-v2 model from Hugging Face Hub on first run, then loads it into memory using Candle.
- Open src/embeddings.rs in your editor and add the imports, struct definition, and constructor by pasting this code:
use anyhow::{anyhow, Result};
use candle_core::{Device, Tensor};
use candle_nn::VarBuilder;
use candle_transformers::models::bert::{BertModel, Config as BertConfig};
use hf_hub::api::tokio::Api;
use tokenizers::Tokenizer;
pub struct LocalEmbedder {
model: BertModel,
tokenizer: Tokenizer,
device: Device,
}
impl LocalEmbedder {
pub async fn new() -> Result<Self> {
let device = Device::Cpu;
let api = Api::new()?;
let repo = api.model("sentence-transformers/all-MiniLM-L6-v2".to_string());
// Download model files
let weights_path = repo.get("model.safetensors").await?;
let tokenizer_path = repo.get("tokenizer.json").await?;
let config_path = repo.get("config.json").await?;
// Load config
let config_str = std::fs::read_to_string(&config_path)?;
let config: BertConfig = serde_json::from_str(&config_str)?;
// Load model weights
let vb = unsafe {
VarBuilder::from_mmaped_safetensors(&[weights_path], candle_core::DType::F32, &device)?
};
let model = BertModel::load(vb, &config)?;
// Load tokenizer
let tokenizer = Tokenizer::from_file(&tokenizer_path)
.map_err(|e| anyhow!("Failed to load tokenizer: {}", e))?;
Ok(Self {
model,
tokenizer,
device,
})
}
}
What does this code do?
The LocalEmbedder struct holds three things: the BERT model, a tokenizer, and a device (CPU). The new() constructor is async because it downloads files from Hugging Face Hub the first time you run it.
It fetches three files: model.safetensors (the model weights), tokenizer.json (the tokenizer config), and config.json (the model architecture config). Then it loads them into memory using Candle's VarBuilder and BertModel::load.
Why use unsafe for VarBuilder?
The from_mmaped_safetensors method memory-maps the weights file directly from disk. This is much faster than reading the whole file into memory. It requires unsafe because Rust cannot guarantee the file won't be modified while it's mapped. In practice, this is perfectly safe for model inference.
- Save src/embeddings.rs.
- You won't see a change yet. The next chunk adds the method that actually generates embeddings.
Implement the embed_text method
With the model loaded, you need a method that takes a string, tokenizes it, runs it through the BERT model, and produces a normalized 384-dimensional vector.
- In src/embeddings.rs, find the closing brace of the new() method. Add the embed_text method inside the same impl LocalEmbedder block, before the final }:
pub fn embed_text(&self, text: &str) -> Result<Vec<f32>> {
let encoding = self
.tokenizer
.encode(text, true)
.map_err(|e| anyhow!("Tokenization error: {}", e))?;
let token_ids = encoding.get_ids().to_vec();
let token_type_ids = encoding.get_type_ids().to_vec();
let token_ids_tensor =
Tensor::new(vec![token_ids.clone()], &self.device)?;
let token_type_ids_tensor =
Tensor::new(vec![token_type_ids], &self.device)?;
let embeddings = self
.model
.forward(&token_ids_tensor, &token_type_ids_tensor, None)?;
// Mean pooling over token dimension
let seq_len = token_ids.len();
let sum = embeddings.sum(1)?;
let mean = (sum / seq_len as f64)?;
// Normalize
let norm = mean.sqr()?.sum(1)?.sqrt()?;
let normalized = mean.broadcast_div(&norm)?;
let embedding_vec: Vec<f32> = normalized.squeeze(0)?.to_vec1()?;
Ok(embedding_vec)
}
What does this code do?
- Tokenization: The tokenizer splits the input text into subword tokens and returns their numeric IDs plus type IDs (needed by BERT).
- Forward pass: The token tensors go through the BERT model, producing an embedding for each token in the sequence.
- Mean pooling: Instead of using just the first token's embedding, we average all token embeddings to get a single vector representing the whole input.
- L2 normalization: The vector is normalized to unit length. This ensures cosine similarity calculations work correctly later.
- Save src/embeddings.rs.
- You won't see a change yet. The next chunk builds the knowledge base that uses this embedding method.
Create the KnowledgeBase with sample documents
The KnowledgeBase holds pre-embedded documents and provides a semantic search method. When it initializes, it embeds six sample documents covering AI, climate, and space topics.
- Below the closing brace of impl LocalEmbedder, add the Document struct, KnowledgeBase struct, and its new() method:
// ─── Knowledge Base ───────────────────────────────────────────────────────────
pub struct Document {
pub id: String,
pub content: String,
pub embedding: Vec<f32>,
}
pub struct KnowledgeBase {
documents: Vec<Document>,
embedder: LocalEmbedder,
}
impl KnowledgeBase {
pub async fn new(embedder: LocalEmbedder) -> Result<Self> {
let sample_docs = vec![
"AI safety research focuses on alignment, ensuring AI systems act in accordance with human values. Key challenges include reward hacking, goal misgeneralization, and deceptive alignment.",
"Large language models are trained on vast text corpora using self-supervised learning. Techniques like RLHF (Reinforcement Learning from Human Feedback) help align model outputs with human preferences.",
"Climate change is driven primarily by greenhouse gas emissions from burning fossil fuels. The Paris Agreement aims to limit global warming to 1.5 degrees Celsius above pre-industrial levels.",
"Renewable energy sources including solar, wind, and hydroelectric power are rapidly scaling. Solar panel efficiency has increased from 6% in 1954 to over 47% in laboratory conditions.",
"Space exploration has entered a new era with reusable rockets. SpaceX's Starship aims to make Mars colonization feasible, while NASA's Artemis program targets a return to the Moon.",
"Quantum computing uses qubits that can exist in superposition states. Current quantum computers have reached over 1000 qubits but error correction remains a major challenge.",
];
let mut documents = Vec::new();
for (i, content) in sample_docs.iter().enumerate() {
let embedding = embedder.embed_text(content)?;
documents.push(Document {
id: format!("doc_{}", i),
content: content.to_string(),
embedding,
});
}
Ok(Self {
documents,
embedder,
})
}
}
What does this code do?
The Document struct pairs a piece of text with its pre-computed embedding vector. The KnowledgeBase stores a collection of these documents plus the embedder itself (so it can embed search queries later).
During initialization, new() loops through six hardcoded research snippets and calls embed_text on each one. This means embeddings are generated at startup. In a production system, you would persist these embeddings to avoid recomputing them.
- Save src/embeddings.rs.
- You won't see a change yet. The search method comes next.
- Still in src/embeddings.rs, add the search method inside the impl KnowledgeBase block (before its closing }), then add the cosine_similarity helper function below it:
pub async fn search(&self, query: &str, num_results: usize) -> Result<Vec<String>> {
let query_embedding = self.embedder.embed_text(query)?;
let mut scored: Vec<(f32, &Document)> = self
.documents
.iter()
.map(|doc| {
let score = cosine_similarity(&query_embedding, &doc.embedding);
(score, doc)
})
.collect();
scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
Ok(scored
.into_iter()
.take(num_results)
.map(|(score, doc)| format!("[Relevance: {:.3}] {}", score, doc.content))
.collect())
}
}
fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
let dot: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
let norm_a: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
let norm_b: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
if norm_a == 0.0 || norm_b == 0.0 {
0.0
} else {
dot / (norm_a * norm_b)
}
}
What does this code do?
- search(): Embeds the query string, computes cosine similarity against every document embedding, sorts by relevance, and returns the top N results with their scores.
- cosine_similarity(): Measures how similar two vectors are by computing the dot product divided by the product of their magnitudes. A score of 1.0 means identical direction. A score of 0.0 means completely unrelated.
- Save src/embeddings.rs.
- Verify your embeddings module compiles by running:
cargo check
You should see a single error about a missing main function (error[E0601]). This is expected because src/main.rs is still empty. The important thing is that you do not see any errors pointing to src/embeddings.rs.
Seeing errors in embeddings.rs?
- If you see a type mismatch on Tensor::new, make sure you are wrapping token_ids in vec![...] to create a batch dimension (a 2D tensor).
- If you see unresolved imports, double-check that your Cargo.toml includes candle-core, candle-nn, candle-transformers, hf-hub, and tokenizers as dependencies.
- If you see an error about KnowledgeBase not being found in tools.rs, that is fine. It resolves once you save src/embeddings.rs with the KnowledgeBase struct defined.
help me debug Candle embedding errors in my Rust project
✔️ Awesome, I've got everything!
Great. Double check you have saved src/embeddings.rs and move on.
ⓧ I'd like to double check the full code
Here is the complete src/embeddings.rs file at the end of this step:
use anyhow::{anyhow, Result};
use candle_core::{Device, Tensor};
use candle_nn::VarBuilder;
use candle_transformers::models::bert::{BertModel, Config as BertConfig};
use hf_hub::api::tokio::Api;
use tokenizers::Tokenizer;
pub struct LocalEmbedder {
model: BertModel,
tokenizer: Tokenizer,
device: Device,
}
impl LocalEmbedder {
pub async fn new() -> Result<Self> {
let device = Device::Cpu;
let api = Api::new()?;
let repo = api.model("sentence-transformers/all-MiniLM-L6-v2".to_string());
// Download model files
let weights_path = repo.get("model.safetensors").await?;
let tokenizer_path = repo.get("tokenizer.json").await?;
let config_path = repo.get("config.json").await?;
// Load config
let config_str = std::fs::read_to_string(&config_path)?;
let config: BertConfig = serde_json::from_str(&config_str)?;
// Load model weights
let vb = unsafe {
VarBuilder::from_mmaped_safetensors(&[weights_path], candle_core::DType::F32, &device)?
};
let model = BertModel::load(vb, &config)?;
// Load tokenizer
let tokenizer = Tokenizer::from_file(&tokenizer_path)
.map_err(|e| anyhow!("Failed to load tokenizer: {}", e))?;
Ok(Self {
model,
tokenizer,
device,
})
}
pub fn embed_text(&self, text: &str) -> Result<Vec<f32>> {
let encoding = self
.tokenizer
.encode(text, true)
.map_err(|e| anyhow!("Tokenization error: {}", e))?;
let token_ids = encoding.get_ids().to_vec();
let token_type_ids = encoding.get_type_ids().to_vec();
let token_ids_tensor =
Tensor::new(vec![token_ids.clone()], &self.device)?;
let token_type_ids_tensor =
Tensor::new(vec![token_type_ids], &self.device)?;
let embeddings = self
.model
.forward(&token_ids_tensor, &token_type_ids_tensor, None)?;
// Mean pooling over token dimension
let seq_len = token_ids.len();
let sum = embeddings.sum(1)?;
let mean = (sum / seq_len as f64)?;
// Normalize
let norm = mean.sqr()?.sum(1)?.sqrt()?;
let normalized = mean.broadcast_div(&norm)?;
let embedding_vec: Vec<f32> = normalized.squeeze(0)?.to_vec1()?;
Ok(embedding_vec)
}
}
// ─── Knowledge Base ───────────────────────────────────────────────────────────
pub struct Document {
pub id: String,
pub content: String,
pub embedding: Vec<f32>,
}
pub struct KnowledgeBase {
documents: Vec<Document>,
embedder: LocalEmbedder,
}
impl KnowledgeBase {
pub async fn new(embedder: LocalEmbedder) -> Result<Self> {
let sample_docs = vec![
"AI safety research focuses on alignment, ensuring AI systems act in accordance with human values. Key challenges include reward hacking, goal misgeneralization, and deceptive alignment.",
"Large language models are trained on vast text corpora using self-supervised learning. Techniques like RLHF (Reinforcement Learning from Human Feedback) help align model outputs with human preferences.",
"Climate change is driven primarily by greenhouse gas emissions from burning fossil fuels. The Paris Agreement aims to limit global warming to 1.5 degrees Celsius above pre-industrial levels.",
"Renewable energy sources including solar, wind, and hydroelectric power are rapidly scaling. Solar panel efficiency has increased from 6% in 1954 to over 47% in laboratory conditions.",
"Space exploration has entered a new era with reusable rockets. SpaceX's Starship aims to make Mars colonization feasible, while NASA's Artemis program targets a return to the Moon.",
"Quantum computing uses qubits that can exist in superposition states. Current quantum computers have reached over 1000 qubits but error correction remains a major challenge.",
];
let mut documents = Vec::new();
for (i, content) in sample_docs.iter().enumerate() {
let embedding = embedder.embed_text(content)?;
documents.push(Document {
id: format!("doc_{}", i),
content: content.to_string(),
embedding,
});
}
Ok(Self {
documents,
embedder,
})
}
pub async fn search(&self, query: &str, num_results: usize) -> Result<Vec<String>> {
let query_embedding = self.embedder.embed_text(query)?;
let mut scored: Vec<(f32, &Document)> = self
.documents
.iter()
.map(|doc| {
let score = cosine_similarity(&query_embedding, &doc.embedding);
(score, doc)
})
.collect();
scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
Ok(scored
.into_iter()
.take(num_results)
.map(|(score, doc)| format!("[Relevance: {:.3}] {}", score, doc.content))
.collect())
}
}
fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
let dot: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
let norm_a: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
let norm_b: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
if norm_a == 0.0 || norm_b == 0.0 {
0.0
} else {
dot / (norm_a * norm_b)
}
}
Your local embedding system is complete. Next up, you will build the agent pipeline that uses this knowledge base to answer research questions.
Build the Agent Pipeline
You now have the building blocks: custom tools that agents can call, and a knowledge base powered by local Candle embeddings. The missing piece is the orchestration layer that chains these components into a coherent workflow.
In this step, you'll build a multi-stage pipeline where each agent has a single job. A planner breaks the topic into sub-questions, a retriever fetches relevant context, and a synthesizer combines everything into a final answer.
In this step, get ready to:
- Create a research planner agent that decomposes topics into focused sub-questions.
- Build a knowledge retrieval stage that queries the vector store for each sub-question.
- Create a synthesizer agent with the Summarize tool and demonstrate Rig's pipeline composition API.
Create the research planner agent
The first stage of the pipeline is a planner agent. Its job is to take a broad topic and decompose it into 3 focused sub-questions that guide the retrieval stage.
- Open src/pipeline.rs in your editor.
- Add the imports and the first half of the build_and_run_pipeline function by pasting this code:
use anyhow::Result;
use rig::completion::Prompt;
use rig::pipeline::{self, Op};
use rig::providers::openai;
use std::sync::Arc;
use tokio::sync::RwLock;
use crate::embeddings::KnowledgeBase;
use crate::tools::Summarize;
pub async fn build_and_run_pipeline(
openai_client: &openai::Client,
kb: Arc<RwLock<KnowledgeBase>>,
query: &str,
) -> Result<String> {
// Stage 1: Research Planner Agent
let planner_agent = openai_client
.agent("gpt-4o-mini")
.preamble(
"You are a research planner. Given a topic, decompose it into exactly 3 focused \
sub-questions that would help thoroughly answer the topic. Return ONLY the 3 \
questions, one per line, numbered 1-3. No other text.",
)
.temperature(0.3)
.build();
// Stage 2: Get sub-questions from the planner
println!(" [Stage 1] Planning research questions...");
let sub_questions = planner_agent.prompt(query).await?;
println!(" Sub-questions:\n{}", sub_questions);
What does this code do?
The imports bring in Rig's pipeline and completion traits, plus the shared types you built in earlier steps.
- rig::completion::Prompt is the trait that enables .prompt(query).await on agents.
- rig::pipeline::{self, Op} gives access to Rig's DAG-based pipeline composition and the Op trait for calling pipeline stages.
- The planner agent uses .temperature(0.3) for deterministic output and a strict preamble that forces exactly 3 numbered sub-questions.
- Save src/pipeline.rs.
- You won't see output yet. The next substep adds the retrieval stage that uses these sub-questions.
Create the knowledge retrieval stage
Once the planner produces sub-questions, the retrieval stage queries your KnowledgeBase for the 2 most relevant documents per question. This is the RAG pattern in action: grounding the final answer in real retrieved context.
- In src/pipeline.rs, add the retrieval stage directly below the last println! line you just wrote by pasting this code:
// Stage 3: Retrieve context for each sub-question
println!("\n [Stage 2] Retrieving knowledge...");
let kb_read = kb.read().await;
let mut all_context = Vec::new();
for line in sub_questions.lines() {
let question = line.trim();
if question.is_empty() {
continue;
}
let results = kb_read.search(question, 2).await?;
all_context.extend(results);
}
drop(kb_read);
let context_block = all_context.join("\n\n");
println!(" Retrieved {} context passages.", all_context.len());
What does this code do?
- kb.read().await acquires a read lock on the shared KnowledgeBase. The Arc<RwLock<...>> pattern allows safe concurrent access in async Rust.
- The loop iterates over each sub-question line, trims whitespace, skips empty lines, and retrieves the top 2 matching documents from the vector store.
- drop(kb_read) explicitly releases the read lock before moving on. This is important because holding locks across .await points can cause deadlocks.
- context_block joins all retrieved passages into a single string that gets passed to the synthesizer.
- Save src/pipeline.rs.
- You won't see output yet. The next chunk completes the pipeline with the synthesizer agent.
Create the synthesizer agent and demonstrate the pipeline API
The final stage of build_and_run_pipeline is a synthesizer agent that receives all retrieved context and produces a comprehensive answer. You'll also add a standalone function demonstrating Rig's composable pipeline::new() API.
- In src/pipeline.rs, add the synthesizer stage directly below the println!(" Retrieved {} context passages.", all_context.len()); line by pasting this code:
// Stage 4: Synthesizer Agent with Summarize tool
println!("\n [Stage 3] Synthesizing answer...");
let synthesizer_agent = openai_client
.agent("gpt-4o-mini")
.preamble(
"You are a research synthesizer. Given a user's question and retrieved context \
documents, provide a comprehensive, well-structured answer. Cite the context \
where relevant. If the context doesn't fully answer the question, say so and \
provide what you can based on the available information.",
)
.tool(Summarize)
.temperature(0.5)
.build();
let synthesis_prompt = format!(
"Original question: {}\n\n\
Retrieved context:\n{}\n\n\
Please synthesize a comprehensive answer based on the above context.",
query, context_block
);
let final_answer = synthesizer_agent.prompt(&synthesis_prompt).await?;
Ok(final_answer)
}
What does the synthesizer do?
- .tool(Summarize) attaches the Summarize tool you built earlier. The agent can autonomously decide to invoke it if the context is too long.
- .temperature(0.5) balances creativity and factual grounding. Lower than the default 1.0, but not as strict as the planner's 0.3.
- The synthesis_prompt combines the original query with all retrieved context, giving the agent full visibility into what was found.
- Now add the demo_pipeline_api function at the bottom of src/pipeline.rs to demonstrate Rig's composable pipeline API:
// ─── Pipeline-style composition (demonstrating Rig's pipeline API) ────────────
pub async fn demo_pipeline_api(openai_client: &openai::Client) -> Result<()> {
println!("\n--- Pipeline API Demo ---\n");
let agent = openai_client
.agent("gpt-4o-mini")
.preamble("You are a helpful assistant. Keep responses under 2 sentences.")
.build();
// Demonstrate Rig's pipeline composition
let pipeline = pipeline::new()
.map(|topic: String| {
format!(
"Explain the following topic in simple terms: {}",
topic
)
})
.prompt(&agent);
let result = pipeline.call("quantum computing".to_string()).await;
println!("Pipeline result: {}", result);
Ok(())
}
What does demo_pipeline_api show?
This function demonstrates Rig's functional pipeline composition in its simplest form.
- pipeline::new() starts a new pipeline chain.
- .map() applies a synchronous transformation (formatting the topic into a full prompt string).
- .prompt(&agent) sends the transformed string to the agent and awaits its response.
- pipeline.call() executes the full chain. The Op trait provides this method.
Why two styles of pipeline?
The build_and_run_pipeline function uses explicit agent calls and manual data passing between stages. This gives you full control over error handling and intermediate logging.
The demo_pipeline_api function uses Rig's declarative pipeline::new() API which composes operations into a single callable chain. Both patterns are valid. The explicit style works best when you need visibility into each stage.
- Save src/pipeline.rs.
- The pipeline module is complete. You won't be able to run it until the next step wires src/main.rs to call these functions.
Seeing red squiggles in your editor?
- Your editor's Rust analyzer may show errors because src/main.rs is still empty. This is expected. The project will compile once you add the main function in the next step.
- Check that your use crate::embeddings::KnowledgeBase and use crate::tools::Summarize imports match the struct names in your other files exactly.
Why is my pipeline.rs showing import errors?
✔️ Awesome, I've got everything!
Great. Double check you've saved src/pipeline.rs and move on to the next step.
ⓧ I'd like to double check the full code
Here is the complete src/pipeline.rs file. Compare yours against this:
use anyhow::Result;
use rig::completion::Prompt;
use rig::pipeline::{self, Op};
use rig::providers::openai;
use std::sync::Arc;
use tokio::sync::RwLock;
use crate::embeddings::KnowledgeBase;
use crate::tools::Summarize;
pub async fn build_and_run_pipeline(
openai_client: &openai::Client,
kb: Arc<RwLock<KnowledgeBase>>,
query: &str,
) -> Result<String> {
// Stage 1: Research Planner Agent
let planner_agent = openai_client
.agent("gpt-4o-mini")
.preamble(
"You are a research planner. Given a topic, decompose it into exactly 3 focused \
sub-questions that would help thoroughly answer the topic. Return ONLY the 3 \
questions, one per line, numbered 1-3. No other text.",
)
.temperature(0.3)
.build();
// Stage 2: Get sub-questions from the planner
println!(" [Stage 1] Planning research questions...");
let sub_questions = planner_agent.prompt(query).await?;
println!(" Sub-questions:\n{}", sub_questions);
// Stage 3: Retrieve context for each sub-question
println!("\n [Stage 2] Retrieving knowledge...");
let kb_read = kb.read().await;
let mut all_context = Vec::new();
for line in sub_questions.lines() {
let question = line.trim();
if question.is_empty() {
continue;
}
let results = kb_read.search(question, 2).await?;
all_context.extend(results);
}
drop(kb_read);
let context_block = all_context.join("\n\n");
println!(" Retrieved {} context passages.", all_context.len());
// Stage 4: Synthesizer Agent with Summarize tool
println!("\n [Stage 3] Synthesizing answer...");
let synthesizer_agent = openai_client
.agent("gpt-4o-mini")
.preamble(
"You are a research synthesizer. Given a user's question and retrieved context \
documents, provide a comprehensive, well-structured answer. Cite the context \
where relevant. If the context doesn't fully answer the question, say so and \
provide what you can based on the available information.",
)
.tool(Summarize)
.temperature(0.5)
.build();
let synthesis_prompt = format!(
"Original question: {}\n\n\
Retrieved context:\n{}\n\n\
Please synthesize a comprehensive answer based on the above context.",
query, context_block
);
let final_answer = synthesizer_agent.prompt(&synthesis_prompt).await?;
Ok(final_answer)
}
// ─── Pipeline-style composition (demonstrating Rig's pipeline API) ────────────
pub async fn demo_pipeline_api(openai_client: &openai::Client) -> Result<()> {
println!("\n--- Pipeline API Demo ---\n");
let agent = openai_client
.agent("gpt-4o-mini")
.preamble("You are a helpful assistant. Keep responses under 2 sentences.")
.build();
// Demonstrate Rig's pipeline composition
let pipeline = pipeline::new()
.map(|topic: String| {
format!(
"Explain the following topic in simple terms: {}",
topic
)
})
.prompt(&agent);
let result = pipeline.call("quantum computing".to_string()).await;
println!("Pipeline result: {}", result);
Ok(())
}
Your pipeline module is ready. Next up, you'll wire everything together in src/main.rs and run the full research pipeline end-to-end.
Wire Everything Together and Run
You now have all the building blocks: custom tools in tools.rs, local embeddings in embeddings.rs, and pipeline orchestration in pipeline.rs.
The final piece is main.rs, where you initialize every component, connect them together, and fire off the pipeline with a real query. This is where your research assistant comes to life.
In this step, get ready to:
- Wire up the OpenAI client, local embedder, and knowledge base in main.rs.
- Run the full pipeline end-to-end with a sample query.
- Verify the pipeline handles errors gracefully with anyhow.
Initialize the clients and compose the pipeline
The main.rs file is the entry point that ties all your modules together. It initializes the OpenAI client, loads the Candle-based embedding model, builds the knowledge base, and then runs both the pipeline API demo and the full research pipeline.
- Open src/main.rs in your editor and paste the following code:
mod embeddings;
mod pipeline;
mod tools;
use anyhow::Result;
use rig::providers::openai;
use std::sync::Arc;
use tokio::sync::RwLock;
use crate::embeddings::{KnowledgeBase, LocalEmbedder};
#[tokio::main]
async fn main() -> Result<()> {
println!("=== Research Pipeline Agent ===\n");
// Initialize OpenAI client
let openai_client = openai::Client::from_env()?;
println!("[Init] OpenAI client ready.");
// Initialize local embeddings with Candle
println!("[Init] Loading embedding model (all-MiniLM-L6-v2)...");
let embedder = LocalEmbedder::new().await?;
println!("[Init] Embedding model loaded.");
// Build knowledge base with sample documents
println!("[Init] Building knowledge base...");
let kb = KnowledgeBase::new(embedder).await?;
let kb = Arc::new(RwLock::new(kb));
println!("[Init] Knowledge base ready with sample documents.\n");
// Demo: Rig's pipeline API
pipeline::demo_pipeline_api(&openai_client).await?;
// Run the full research pipeline
println!("\n--- Full Research Pipeline ---\n");
let query = "What are the latest developments in AI safety?";
println!("Query: \"{}\"\n", query);
let answer = pipeline::build_and_run_pipeline(&openai_client, kb.clone(), query).await?;
println!("\n=== Final Answer ===\n");
println!("{}", answer);
Ok(())
}
What does this code do?
The mod declarations at the top tell Rust about your other source files. Without these, the compiler cannot find embeddings.rs, pipeline.rs, or tools.rs.
The initialization block creates each component in sequence. openai::Client::from_env()? reads your OPENAI_API_KEY environment variable automatically. LocalEmbedder::new() downloads the BERT model from Hugging Face on first run.
The knowledge base is wrapped in Arc<RwLock<>> so it can be shared safely across async tasks. This is a common Rust pattern for concurrent access to shared data.
- Save src/main.rs.
Seeing unresolved import errors?
Make sure your mod embeddings;, mod pipeline;, and mod tools; declarations are at the very top of the file, before any use statements.
Verify that src/embeddings.rs, src/pipeline.rs, and src/tools.rs all exist in your src/ directory.
Help me debug unresolved import errors in my Rust main.rs
Run the pipeline with a sample query
With all modules connected, you can now run the full pipeline. The first run will take longer because hf-hub downloads the all-MiniLM-L6-v2 model weights from Hugging Face (about 90 MB).
- Run the pipeline by executing this command in your terminal:
cargo run
What should I see?
The output walks you through each stage of the pipeline as it executes:
- Initialization messages confirming the OpenAI client, embedding model, and knowledge base are loaded.
- A "Pipeline API Demo" section showing Rig's pipeline composition in action.
- Stage 1 output showing the 3 sub-questions the planner generated.
- Stage 2 showing how many context passages were retrieved from the knowledge base.
- Stage 3 printing the final synthesized answer about AI safety.
Seeing an OPENAI_API_KEY error?
If you get an error about a missing API key, make sure you exported it in your current terminal session. The openai::Client::from_env() call reads from the OPENAI_API_KEY environment variable.
If the model download stalls, check your internet connection. The first run needs to fetch model weights from Hugging Face.
Help me troubleshoot cargo run errors in my research-pipeline project
Test with different query types
The pipeline uses anyhow for error handling throughout. Every ? operator in the code propagates errors upward with full context, so if any stage fails you get a clear error message instead of a panic.
- Try changing the query string in src/main.rs to test different topics.
- For example, try a climate-related query like "What is driving renewable energy adoption?" or a space query like "What are the goals of current space programs?".
- Run cargo run again after each change and observe how the retrieved context shifts to match the topic.
Why does context relevance matter?
When you change the query topic, you should see the relevance scores in the retrieved passages shift. A climate query pulls climate and energy documents with high scores, while AI safety content drops to low relevance.
This demonstrates that your Candle embeddings are producing meaningful vector representations. The cosine similarity search is finding semantically related documents, not just keyword matches.
- Once you have finished testing, change the query back to "What are the latest developments in AI safety?" and save src/main.rs.
Pipeline returning irrelevant results?
If the retrieved passages don't match your query topic, double check that the knowledge base initialization in embeddings.rs contains all six sample documents. A missing document means fewer potential matches.
If every query returns the same results regardless of topic, the embeddings may not be loading correctly. Look for error messages during the "Building knowledge base" initialization phase.
Help me debug irrelevant RAG retrieval results in my Candle embeddings pipeline
✔️ Awesome, I've got everything!
Great. Double check you have saved src/main.rs with the AI safety query as the final version.
ⓧ I'd like to double check the full code
Here is the complete src/main.rs for comparison:
mod embeddings;
mod pipeline;
mod tools;
use anyhow::Result;
use rig::providers::openai;
use std::sync::Arc;
use tokio::sync::RwLock;
use crate::embeddings::{KnowledgeBase, LocalEmbedder};
#[tokio::main]
async fn main() -> Result<()> {
println!("=== Research Pipeline Agent ===\n");
// Initialize OpenAI client
let openai_client = openai::Client::from_env()?;
println!("[Init] OpenAI client ready.");
// Initialize local embeddings with Candle
println!("[Init] Loading embedding model (all-MiniLM-L6-v2)...");
let embedder = LocalEmbedder::new().await?;
println!("[Init] Embedding model loaded.");
// Build knowledge base with sample documents
println!("[Init] Building knowledge base...");
let kb = KnowledgeBase::new(embedder).await?;
let kb = Arc::new(RwLock::new(kb));
println!("[Init] Knowledge base ready with sample documents.\n");
// Demo: Rig's pipeline API
pipeline::demo_pipeline_api(&openai_client).await?;
// Run the full research pipeline
println!("\n--- Full Research Pipeline ---\n");
let query = "What are the latest developments in AI safety?";
println!("Query: \"{}\"\n", query);
let answer = pipeline::build_and_run_pipeline(&openai_client, kb.clone(), query).await?;
println!("\n=== Final Answer ===\n");
println!("{}", answer);
Ok(())
}
Your multi-agent research pipeline is running end-to-end. You have a planner decomposing questions, a retriever pulling relevant context from your local knowledge base, and a synthesizer combining everything into a coherent answer. Next up, take on the Secret Mission to add dynamic routing that picks different pipeline branches based on query type.
Secret mission
Add a Dynamic Routing Agent
Your pipeline currently sends every query through the full RAG system. But what if the user asks for a poem instead of a research question? Build a routing agent that classifies incoming queries and dynamically picks the right pipeline branch to execute.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs locally and only incurs OpenAI API costs when you actively run the program, so there are no ongoing charges while idle.
Resources you used:
- The research-pipeline project folder on your Desktop.
- Cached Hugging Face model files (all-MiniLM-L6-v2) stored in ~/.cache/huggingface/hub/.
- The OPENAI_API_KEY environment variable set in your terminal session.
Keep everything running
No action needed. Choose this if you plan to keep experimenting with the pipeline or extending it with new agents and tools.
- Your project folder stays on your Desktop, ready to compile and run again.
- The cached model files save you from re-downloading on the next run.
- Your OPENAI_API_KEY remains available for future API calls. Each run costs a small amount (typically under $0.01 per execution).
Pause - I'll come back to this later
Free up disk space from compiled artifacts while keeping your source code and model cache intact.
- Remove the compiled build artifacts by running:
cd ~/Desktop/research-pipeline
cargo clean
- This deletes the target/ directory (which can be several hundred MB) but keeps all your source files.
- When you come back, run cargo build to recompile everything.
Delete - I don't want to use this again
Remove all project resources and cached files to start completely fresh.
- Delete the project folder by running:
cd ~/Desktop
rm -rf research-pipeline
- Delete the cached Hugging Face model files by running:
rm -rf ~/.cache/huggingface/hub/models--sentence-transformers--all-MiniLM-L6-v2
- Unset the environment variable in your current terminal session by running:
unset OPENAI_API_KEY
What about my OpenAI account?
Your OpenAI account and API key remain active after this cleanup. If you want to revoke the specific key you used, visit the API keys page in your OpenAI dashboard and delete it from there.
Nice Work!
Nice Work!
You just built a production-pattern AI agent pipeline entirely in Rust. Your application takes a topic, breaks it into sub-questions, retrieves relevant context from a local knowledge base, and synthesizes a comprehensive answer.
You've learned how to:
- Built custom tools (Summarize and SearchKnowledgeBase) implementing Rig's Tool trait for autonomous agent tool-calling.
- Set up local embeddings using Candle with the all-MiniLM-L6-v2 model and built a RAG system powered by cosine similarity search.
- Composed a multi-stage agent pipeline using Rig's pipeline::new(), .map(), and .prompt() API to orchestrate planner, retriever, and synthesizer agents.
- Secret Mission: Added a dynamic routing agent that classifies queries and branches the pipeline between factual (RAG) and creative generation paths.
Ready to quiz yourself?