# Directory Structure
```
├── .cargo
│ └── config.toml
├── .env
├── .gitignore
├── .vscode
│ ├── extensions.json
│ └── settings.json
├── Cargo.lock
├── Cargo.toml
├── data
│ └── .keep
├── documents
│ ├── .DS_Store
│ └── .keep
├── how-to-use.md
├── Makefile
├── README.md
├── setup.md
└── src
├── embeddings.rs
├── main.rs
├── mcp_server.rs
└── rag_engine.rs
```
# Files
--------------------------------------------------------------------------------
/data/.keep:
--------------------------------------------------------------------------------
```
1 |
```
--------------------------------------------------------------------------------
/documents/.keep:
--------------------------------------------------------------------------------
```
1 |
```
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
```
1 | /target
2 | /logs
3 |
4 | documents/*.pdf
5 | data/chunks.json
6 |
7 | !documents/.keep
8 | !data/.keep
9 |
```
--------------------------------------------------------------------------------
/.env:
--------------------------------------------------------------------------------
```
1 | DATA_DIR=./data
2 | DOCUMENTS_DIR=./documents
3 | LOG_DIR=./logs
4 | LOG_LEVEL=info
5 | LOG_MAX_MB=5
6 |
7 | # Development and debugging configuration (uncomment to enable)
8 | # DEVELOPMENT=true
9 | # DEV=true
10 | # CONSOLE_LOGS=true
11 |
```
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "recommendations": [
3 | "rust-lang.rust-analyzer",
4 | "vadimcn.vscode-lldb",
5 | "serayuzgur.crates"
6 | ]
7 | }
8 |
```
--------------------------------------------------------------------------------
/.cargo/config.toml:
--------------------------------------------------------------------------------
```toml
1 | [alias]
2 | c = "check"
3 | cc = "clippy"
4 | ccf = "clippy --fix --allow-dirty --allow-staged"
5 | ccd = ["clippy", "--", "-D", "warnings"]
6 | f = "fmt"
7 | t = "test"
8 | r = "run"
9 | b = "build"
10 | br = "build --release"
11 |
12 | [build]
13 | rustflags = ["-D", "warnings"]
14 |
```
--------------------------------------------------------------------------------
/Cargo.toml:
--------------------------------------------------------------------------------
```toml
1 | [package]
2 | name = "rust-local-rag"
3 | version = "0.1.0"
4 | edition = "2024"
5 | authors = ["Mehmet Koray Sariteke"]
6 | description = "A local RAG (Retrieval-Augmented Generation) MCP server built with Rust"
7 | license = "MIT"
8 | readme = "README.md"
9 | repository = "https://github.com/yourusername/rust-local-rag"
10 | keywords = ["rag", "ai", "llm", "embeddings", "pdf", "search", "mcp"]
11 | categories = ["command-line-utilities"]
12 |
13 | [[bin]]
14 | name = "rust-local-rag"
15 | path = "src/main.rs"
16 |
17 | [dependencies]
18 | rmcp = { version = "0.1", features = ["transport-io"] }
19 | rmcp-macros = "0.1"
20 | schemars = "0.9"
21 | tokio = { version = "1", features = ["full"] }
22 | serde = { version = "1.0", features = ["derive"] }
23 | serde_json = "1.0"
24 | reqwest = { version = "0.12", features = ["json"] }
25 | anyhow = "1.0"
26 | tracing = "0.1"
27 | tracing-subscriber = { version = "0.3", features = [
28 | "fmt",
29 | "json",
30 | "time",
31 | "env-filter",
32 | ] }
33 | uuid = { version = "1.17", features = ["v4"] }
34 | walkdir = "2.5"
35 | dotenv = "0.15"
36 |
```
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "rust-analyzer.server.path": "rust-analyzer",
3 | "rust-analyzer.cargo.buildScripts.enable": true,
4 | "rust-analyzer.checkOnSave.command": "cargo check",
5 | "rust-analyzer.completion.addCallParentheses": true,
6 | "rust-analyzer.completion.addCallArgumentSnippets": true,
7 | "rust-analyzer.inlayHints.enable": true,
8 | "rust-analyzer.inlayHints.parameterHints.enable": true,
9 | "rust-analyzer.inlayHints.typeHints.enable": true,
10 | "rust-analyzer.lens.enable": true,
11 | "rust-analyzer.lens.methodReferences": true,
12 | "rust-analyzer.hover.actions.enable": true,
13 | "editor.formatOnSave": true,
14 | "[rust]": {
15 | "editor.defaultFormatter": "rust-lang.rust-analyzer",
16 | "editor.tabSize": 4,
17 | "editor.insertSpaces": true
18 | },
19 | "rust-analyzer.diagnostics.enable": true,
20 | "rust-analyzer.procMacro.enable": true,
21 | "rust-analyzer.cargo.allFeatures": true,
22 | "rust-analyzer.cargo.loadOutDirsFromCheck": true,
23 | "rust-analyzer.cargo.runBuildScripts": true,
24 | "rust-analyzer.workspace.symbol.search.scope": "workspace_and_dependencies",
25 | "rust-analyzer.workspace.symbol.search.kind": "all_symbols",
26 | "rust-analyzer.check.command": "clippy",
27 | "rust-analyzer.check.allTargets": false,
28 | "rust-analyzer.checkOnSave": true,
29 | "rust-analyzer.cargo.autoreload": true
30 | }
31 |
```
--------------------------------------------------------------------------------
/src/embeddings.rs:
--------------------------------------------------------------------------------
```rust
1 | use anyhow::Result;
2 | use serde::{Deserialize, Serialize};
3 |
4 | #[derive(Serialize)]
5 | struct OllamaEmbeddingRequest {
6 | model: String,
7 | prompt: String,
8 | }
9 |
10 | #[derive(Deserialize)]
11 | struct OllamaEmbeddingResponse {
12 | embedding: Vec<f32>,
13 | }
14 |
15 | pub struct EmbeddingService {
16 | client: reqwest::Client,
17 | ollama_url: String,
18 | model: String,
19 | }
20 |
21 | impl EmbeddingService {
22 | pub async fn new() -> Result<Self> {
23 | let service = Self {
24 | client: reqwest::Client::new(),
25 | ollama_url: "http://localhost:11434".to_string(),
26 | model: "nomic-embed-text".to_string(),
27 | };
28 |
29 | service.test_connection().await?;
30 |
31 | Ok(service)
32 | }
33 |
34 | pub async fn get_embedding(&self, text: &str) -> Result<Vec<f32>> {
35 | let request = OllamaEmbeddingRequest {
36 | model: self.model.clone(),
37 | prompt: text.to_string(),
38 | };
39 |
40 | let response = self
41 | .client
42 | .post(format!("{}/api/embeddings", self.ollama_url))
43 | .json(&request)
44 | .send()
45 | .await?;
46 |
47 | if !response.status().is_success() {
48 | return Err(anyhow::anyhow!(
49 | "Ollama API error: {} - {}",
50 | response.status(),
51 | response.text().await.unwrap_or_default()
52 | ));
53 | }
54 |
55 | let embedding_response: OllamaEmbeddingResponse = response.json().await?;
56 | Ok(embedding_response.embedding)
57 | }
58 |
59 | async fn test_connection(&self) -> Result<()> {
60 | let response = self
61 | .client
62 | .get(format!("{}/api/tags", self.ollama_url))
63 | .send()
64 | .await?;
65 |
66 | if !response.status().is_success() {
67 | return Err(anyhow::anyhow!(
68 | "Cannot connect to Ollama at {}. Make sure Ollama is running.",
69 | self.ollama_url
70 | ));
71 | }
72 |
73 | tracing::info!("Successfully connected to Ollama at {}", self.ollama_url);
74 | Ok(())
75 | }
76 | }
77 |
```
--------------------------------------------------------------------------------
/setup.md:
--------------------------------------------------------------------------------
```markdown
1 | # Rust Local RAG - Setup for Claude Desktop
2 |
3 | ## Prerequisites
4 |
5 | ### 1. Install Rust
6 | ```bash
7 | curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
8 | source ~/.cargo/env
9 | ```
10 |
11 | ### 2. Install Ollama
12 | ```bash
13 | # macOS
14 | brew install ollama
15 |
16 | # Linux
17 | curl -fsSL https://ollama.com/install.sh | sh
18 |
19 | # Start Ollama
20 | ollama serve
21 |
22 | # Install embedding model
23 | ollama pull nomic-embed-text
24 | ```
25 |
26 | ### 3. Install Poppler (for PDF parsing)
27 | ```bash
28 | # macOS
29 | brew install poppler
30 |
31 | # Linux (Ubuntu/Debian)
32 | sudo apt-get install poppler-utils
33 |
34 | # Linux (CentOS/RHEL)
35 | sudo yum install poppler-utils
36 | ```
37 |
38 | ## Setup
39 |
40 | ### 1. Build and Install
41 | ```bash
42 | # Clone and build
43 | git clone <repository-url>
44 | cd rust-local-rag
45 | cargo build --release
46 |
47 | # Install globally
48 | cargo install --path .
49 | ```
50 |
51 | ### 2. Create Directories and Add Documents
52 | ```bash
53 | # Create required directories
54 | mkdir -p ~/Documents/data
55 | mkdir -p ~/Documents/rag
56 | mkdir -p /tmp/rust-local-rag
57 |
58 | # Add your PDF documents
59 | cp your-pdfs/*.pdf ~/Documents/rag/
60 | ```
61 |
62 | ## Claude Desktop Integration
63 |
64 | ### 1. Find Claude Desktop Config
65 | - **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json`
66 | - **Windows**: `%APPDATA%\Claude\claude_desktop_config.json`
67 | - **Linux**: `~/.config/Claude/claude_desktop_config.json`
68 |
69 | ### 2. Add This Configuration
70 | ```json
71 | {
72 | "mcpServers": {
73 | "rust-local-rag": {
74 | "command": "/Users/yourusername/.cargo/bin/rust-local-rag",
75 | "env": {
76 | "DATA_DIR": "/Users/yourusername/Documents/data",
77 | "DOCUMENTS_DIR": "/Users/yourusername/Documents/rag",
78 | "LOG_DIR": "/tmp/rust-local-rag",
79 | "LOG_LEVEL": "info",
80 | "LOG_MAX_MB": "10"
81 | }
82 | }
83 | }
84 | }
85 | ```
86 |
87 | **Important**: Replace `yourusername` with your actual username, or use absolute paths specific to your system.
88 |
89 | ### 3. Find Your Actual Paths
90 | ```bash
91 | # Find your cargo bin directory
92 | echo "$HOME/.cargo/bin/rust-local-rag"
93 |
94 | # Verify the binary exists
95 | which rust-local-rag
96 | ```
97 |
98 | ### 4. Restart Claude Desktop
99 |
100 | ## How PDF Processing Works
101 |
102 | The application automatically:
103 |
104 | 1. **Scans the documents directory** on startup for PDF files
105 | 2. **Extracts text** using poppler's `pdftotext` utility
106 | 3. **Chunks the text** into manageable segments (typically 500-1000 characters)
107 | 4. **Generates embeddings** using Ollama's `nomic-embed-text` model
108 | 5. **Stores embeddings** in the data directory for fast retrieval
109 | 6. **Indexes documents** for semantic search
110 |
111 | Supported formats:
112 | - PDF files (via poppler)
113 | - Text extraction preserves basic formatting
114 | - Each document is split into searchable chunks
115 |
116 | ## Troubleshooting
117 |
118 | ### Installation Issues
119 | 1. **Rust not found**: Restart terminal after installing Rust
120 | 2. **Ollama connection failed**: Ensure `ollama serve` is running
121 | 3. **Poppler not found**: Verify installation with `pdftotext --version`
122 |
123 | ### Claude Desktop Issues
124 | 1. **Binary not found**: Check path with `which rust-local-rag`
125 | 2. **Permission denied**: Ensure directories are writable
126 | 3. **No documents indexed**: Check PDF files exist in `DOCUMENTS_DIR`
127 | 4. **Connection failed**: Check logs in `LOG_DIR` directory
128 |
129 | ### PDF Processing Issues
130 | 1. **Text extraction failed**: Ensure PDFs are not password-protected or corrupted
131 | 2. **Empty results**: Some PDFs may be image-only (scanned documents)
132 | 3. **Slow indexing**: Large documents take time to process on first run
133 |
134 | ### Log Files
135 | Check application logs for detailed error information:
136 | ```bash
137 | # View latest logs
138 | tail -f /tmp/rust-local-rag/rust-local-rag.log
139 |
140 | # Check for errors
141 | grep -i error /tmp/rust-local-rag/rust-local-rag.log
142 | ```
143 |
144 | That's it! Your documents will be automatically indexed and searchable in Claude Desktop.
145 |
```
--------------------------------------------------------------------------------
/src/mcp_server.rs:
--------------------------------------------------------------------------------
```rust
1 | use anyhow::Result;
2 | use rmcp::{
3 | Error as McpError, ServerHandler, ServiceExt, model::*, schemars, tool, transport::stdio,
4 | };
5 |
6 | use std::sync::Arc;
7 | use tokio::sync::RwLock;
8 |
9 | use crate::rag_engine::RagEngine;
10 |
11 | #[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
12 | pub struct SearchRequest {
13 | #[schemars(description = "The search query")]
14 | pub query: String,
15 | #[schemars(description = "Number of results to return (default: 5)")]
16 | pub top_k: Option<usize>,
17 | }
18 |
19 | #[derive(Clone)]
20 | pub struct RagMcpServer {
21 | rag_state: Arc<RwLock<RagEngine>>,
22 | }
23 |
24 | #[tool(tool_box)]
25 | impl RagMcpServer {
26 | pub fn new(rag_state: Arc<RwLock<RagEngine>>) -> Self {
27 | Self { rag_state }
28 | }
29 |
30 | #[tool(description = "Search through uploaded documents using semantic similarity")]
31 | async fn search_documents(
32 | &self,
33 | #[tool(aggr)] SearchRequest { query, top_k }: SearchRequest,
34 | ) -> Result<CallToolResult, McpError> {
35 | let top_k = top_k.unwrap_or(5);
36 | let engine = self.rag_state.read().await;
37 |
38 | match engine.search(&query, top_k).await {
39 | Ok(results) => {
40 | let formatted_results = if results.is_empty() {
41 | "No results found.".to_string()
42 | } else {
43 | results
44 | .iter()
45 | .enumerate()
46 | .map(|(i, result)| {
47 | format!(
48 | "**Result {}** (Score: {:.3}) [{}] (Chunk: {})\n{}\n",
49 | i + 1,
50 | result.score,
51 | result.document,
52 | result.chunk_id,
53 | result.text
54 | )
55 | })
56 | .collect::<Vec<_>>()
57 | .join("\n---\n\n")
58 | };
59 |
60 | Ok(CallToolResult::success(vec![Content::text(format!(
61 | "Found {} results for '{}':\n\n{}",
62 | results.len(),
63 | query,
64 | formatted_results
65 | ))]))
66 | }
67 | Err(e) => Ok(CallToolResult {
68 | content: vec![Content::text(format!("Search error: {}", e))],
69 | is_error: Some(true),
70 | }),
71 | }
72 | }
73 |
74 | #[tool(description = "List all uploaded documents")]
75 | async fn list_documents(&self) -> Result<CallToolResult, McpError> {
76 | let engine = self.rag_state.read().await;
77 | let documents = engine.list_documents();
78 |
79 | let response = if documents.is_empty() {
80 | "No documents uploaded yet.".to_string()
81 | } else {
82 | format!(
83 | "Uploaded documents ({}):\n{}",
84 | documents.len(),
85 | documents
86 | .iter()
87 | .enumerate()
88 | .map(|(i, doc)| format!("{}. {}", i + 1, doc))
89 | .collect::<Vec<_>>()
90 | .join("\n")
91 | )
92 | };
93 |
94 | Ok(CallToolResult::success(vec![Content::text(response)]))
95 | }
96 |
97 | #[tool(description = "Get RAG system statistics")]
98 | async fn get_stats(&self) -> Result<CallToolResult, McpError> {
99 | let engine = self.rag_state.read().await;
100 | let stats = engine.get_stats();
101 |
102 | Ok(CallToolResult::success(vec![Content::text(format!(
103 | "RAG System Stats:\n{}",
104 | serde_json::to_string_pretty(&stats).unwrap()
105 | ))]))
106 | }
107 | }
108 |
109 | #[tool(tool_box)]
110 | impl ServerHandler for RagMcpServer {
111 | fn get_info(&self) -> ServerInfo {
112 | ServerInfo {
113 | protocol_version: ProtocolVersion::V_2024_11_05,
114 | capabilities: ServerCapabilities::builder().enable_tools().build(),
115 | server_info: Implementation {
116 | name: "rust-rag-server".to_string(),
117 | version: "0.1.0".to_string(),
118 | },
119 | instructions: Some(
120 | "A Rust-based RAG server for document search and analysis.".to_string(),
121 | ),
122 | }
123 | }
124 | }
125 |
126 | pub async fn start_mcp_server(rag_state: Arc<RwLock<RagEngine>>) -> Result<()> {
127 | tracing::info!("Starting MCP server");
128 |
129 | let server = RagMcpServer::new(rag_state);
130 | let service = server.serve(stdio()).await?;
131 |
132 | service.waiting().await?;
133 |
134 | Ok(())
135 | }
136 |
```
--------------------------------------------------------------------------------
/src/main.rs:
--------------------------------------------------------------------------------
```rust
1 | use anyhow::Result;
2 | use std::sync::Arc;
3 | use tokio::sync::RwLock;
4 | use tracing_subscriber::EnvFilter;
5 |
6 | mod embeddings;
7 | mod mcp_server;
8 | mod rag_engine;
9 |
10 | use rag_engine::RagEngine;
11 |
12 | fn get_data_dir() -> String {
13 | std::env::var("DATA_DIR").unwrap_or_else(|_| "./data".to_string())
14 | }
15 |
16 | fn get_documents_dir() -> String {
17 | std::env::var("DOCUMENTS_DIR").unwrap_or_else(|_| "./documents".to_string())
18 | }
19 |
20 | fn get_log_dir() -> String {
21 | std::env::var("LOG_DIR").unwrap_or_else(|_| {
22 | if std::path::Path::new("/var/log").exists() && is_writable("/var/log") {
23 | "/var/log/rust-local-rag".to_string()
24 | } else {
25 | "./logs".to_string()
26 | }
27 | })
28 | }
29 |
30 | fn get_log_level() -> String {
31 | std::env::var("LOG_LEVEL").unwrap_or_else(|_| "info".to_string())
32 | }
33 |
34 | fn get_log_max_mb() -> u64 {
35 | std::env::var("LOG_MAX_MB")
36 | .ok()
37 | .and_then(|s| s.parse().ok())
38 | .unwrap_or(5)
39 | }
40 |
41 | fn is_writable(path: &str) -> bool {
42 | std::fs::OpenOptions::new()
43 | .create(true)
44 | .write(true)
45 | .open(format!("{}/test_write", path))
46 | .map(|_| {
47 | let _ = std::fs::remove_file(format!("{}/test_write", path));
48 | true
49 | })
50 | .unwrap_or(false)
51 | }
52 |
53 | fn setup_logging() -> Result<()> {
54 | let log_dir = get_log_dir();
55 | let log_level = get_log_level();
56 | let log_max_mb = get_log_max_mb();
57 |
58 | std::fs::create_dir_all(&log_dir)?;
59 |
60 | let env_filter =
61 | EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(&log_level));
62 |
63 | let is_development = std::env::var("DEVELOPMENT").is_ok() || std::env::var("DEV").is_ok();
64 | let force_console = std::env::var("CONSOLE_LOGS").is_ok();
65 |
66 | if is_development || force_console {
67 | tracing_subscriber::fmt()
68 | .with_env_filter(env_filter)
69 | .compact()
70 | .init();
71 | tracing::info!("Development mode: logging to console");
72 | } else {
73 | let log_file = format!("{}/rust-local-rag.log", log_dir);
74 | let file_appender = std::fs::OpenOptions::new()
75 | .create(true)
76 | .append(true)
77 | .open(&log_file)?;
78 |
79 | tracing_subscriber::fmt()
80 | .with_env_filter(env_filter)
81 | .with_writer(file_appender)
82 | .json()
83 | .init();
84 | }
85 |
86 | tracing::info!("Logging initialized");
87 | tracing::info!("Log directory: {}", log_dir);
88 | tracing::info!("Log level: {}", log_level);
89 | tracing::info!("Log max size: {}MB (auto-truncate)", log_max_mb);
90 | tracing::info!("Development mode: {}", is_development || force_console);
91 |
92 | Ok(())
93 | }
94 |
95 | async fn start_log_cleanup_task(log_dir: String, max_mb: u64) {
96 | let max_bytes = max_mb * 1024 * 1024;
97 | let log_file = format!("{}/rust-local-rag.log", log_dir);
98 |
99 | tokio::spawn(async move {
100 | let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(300));
101 |
102 | loop {
103 | interval.tick().await;
104 |
105 | if let Ok(metadata) = std::fs::metadata(&log_file) {
106 | if metadata.len() > max_bytes {
107 | if let Err(e) = std::fs::write(
108 | &log_file,
109 | format!("[LOG TRUNCATED - Size exceeded {}MB]\n", max_mb),
110 | ) {
111 | eprintln!("Failed to truncate log file: {}", e);
112 | }
113 | }
114 | }
115 | }
116 | });
117 | }
118 |
119 | #[tokio::main]
120 | async fn main() -> Result<()> {
121 | if let Err(e) = dotenv::dotenv() {
122 | eprintln!("Warning: Could not load .env file: {}", e);
123 | }
124 | setup_logging()?;
125 |
126 | let data_dir = get_data_dir();
127 | let documents_dir = get_documents_dir();
128 | let log_dir = get_log_dir();
129 | let log_max_mb = get_log_max_mb();
130 |
131 | tokio::fs::create_dir_all(&data_dir).await?;
132 | tokio::fs::create_dir_all(&documents_dir).await?;
133 |
134 | start_log_cleanup_task(log_dir, log_max_mb).await;
135 | tracing::info!("Started automatic log cleanup task (max: {}MB)", log_max_mb);
136 |
137 | let rag_engine = RagEngine::new(&data_dir).await?;
138 | let rag_state = Arc::new(RwLock::new(rag_engine));
139 |
140 | let document_loading_state = rag_state.clone();
141 | let docs_dir = documents_dir.clone();
142 | tokio::spawn(async move {
143 | tracing::info!("Starting document loading in background...");
144 | let mut engine = document_loading_state.write().await;
145 | if let Err(e) = engine.load_documents_from_dir(&docs_dir).await {
146 | tracing::error!("Failed to load documents: {}", e);
147 | } else {
148 | tracing::info!("Document loading completed successfully");
149 | }
150 | });
151 |
152 | tracing::info!("Starting MCP server (stdin/stdout mode)");
153 | tracing::info!("Data directory: {}", data_dir);
154 | tracing::info!("Documents directory: {}", documents_dir);
155 |
156 | mcp_server::start_mcp_server(rag_state).await?;
157 |
158 | Ok(())
159 | }
160 |
```
--------------------------------------------------------------------------------
/how-to-use.md:
--------------------------------------------------------------------------------
```markdown
1 | # Rust Local RAG - Claude Desktop Usage
2 |
3 | ## Claude Desktop Configuration Template
4 |
5 | ```json
6 | {
7 | "mcpServers": {
8 | "rust-local-rag": {
9 | "command": "/Users/yourusername/.cargo/bin/rust-local-rag",
10 | "env": {
11 | "DATA_DIR": "/Users/yourusername/Documents/data",
12 | "DOCUMENTS_DIR": "/Users/yourusername/Documents/rag",
13 | "LOG_DIR": "/tmp/rust-local-rag",
14 | "LOG_LEVEL": "info",
15 | "LOG_MAX_MB": "10"
16 | }
17 | }
18 | }
19 | }
20 | ```
21 |
22 | **Important**: Replace `yourusername` with your actual username. Use absolute paths for reliable operation.
23 |
24 | ## Environment Variables
25 |
26 | | Variable | Description | Default |
27 | |----------|-------------|---------|
28 | | `DATA_DIR` | Embeddings storage directory | `./data` |
29 | | `DOCUMENTS_DIR` | PDF documents directory | `./documents` |
30 | | `LOG_DIR` | Log files directory | `./logs` |
31 | | `LOG_LEVEL` | Logging level (error/warn/info/debug) | `info` |
32 | | `LOG_MAX_MB` | Log file size limit in MB | `5` |
33 |
34 | ## Adding Documents
35 |
36 | ### 1. Add PDFs to Documents Directory
37 | ```bash
38 | # Copy PDFs to your documents directory
39 | cp your-file.pdf ~/Documents/rag/
40 |
41 | # Or move multiple files
42 | mv /path/to/pdfs/*.pdf ~/Documents/rag/
43 | ```
44 |
45 | ### 2. Restart Claude Desktop
46 | The application will automatically:
47 | - Detect new PDF files
48 | - Extract text using poppler
49 | - Generate embeddings
50 | - Index documents for search
51 |
52 | ## Available MCP Tools
53 |
54 | When configured, Claude Desktop can use these tools:
55 |
56 | ### 1. Search Documents
57 | Search through your documents using semantic similarity.
58 | - **Tool**: `search_documents`
59 | - **Parameters**: `query` (string), `top_k` (optional number, default: 5)
60 |
61 | ### 2. List Documents
62 | Get a list of all indexed documents.
63 | - **Tool**: `list_documents`
64 | - **Parameters**: None
65 |
66 | ### 3. Get Statistics
67 | View RAG system statistics and status.
68 | - **Tool**: `get_stats`
69 | - **Parameters**: None
70 |
71 | ## Usage in Claude
72 |
73 | Once configured, you can ask Claude to:
74 |
75 | ### Document Search Examples
76 | - "Search my documents for information about machine learning"
77 | - "What does my documentation say about API authentication?"
78 | - "Find references to database optimization in my PDFs"
79 |
80 | ### Document Management Examples
81 | - "List all the documents you can access"
82 | - "Show me statistics about the document index"
83 | - "How many documents do you have indexed?"
84 |
85 | ### Analysis Examples
86 | - "Summarize the key points from documents about project requirements"
87 | - "Compare what different documents say about security best practices"
88 | - "Find common themes across all my documentation"
89 |
90 | ## PDF Processing Details
91 |
92 | ### Supported PDF Types
93 | - ✅ **Text-based PDFs**: Searchable text content
94 | - ✅ **Mixed content**: PDFs with both text and images
95 | - ⚠️ **Scanned PDFs**: Image-only documents (limited text extraction)
96 | - ❌ **Password-protected**: Encrypted PDFs cannot be processed
97 |
98 | ### Text Extraction Process
99 | 1. **PDF to Text**: Uses poppler's `pdftotext` for reliable extraction
100 | 2. **Text Chunking**: Splits documents into ~500-1000 character segments
101 | 3. **Embedding Generation**: Creates vector embeddings using Ollama
102 | 4. **Indexing**: Stores embeddings for fast semantic search
103 |
104 | ### Performance Notes
105 | - **First-time indexing**: May take several minutes for large document collections
106 | - **Subsequent startups**: Uses cached embeddings for fast loading
107 | - **Memory usage**: Scales with document collection size
108 | - **Search speed**: Sub-second search responses after indexing
109 |
110 | ## Troubleshooting
111 |
112 | ### MCP Server Issues
113 | 1. **Server not connecting**:
114 | ```bash
115 | # Check binary exists and is executable
116 | which rust-local-rag
117 | ls -la ~/.cargo/bin/rust-local-rag
118 | ```
119 |
120 | 2. **Check Claude Desktop logs**:
121 | - **macOS**: `~/Library/Logs/Claude/mcp*.log`
122 | - **Windows**: `%APPDATA%\Claude\Logs\mcp*.log`
123 | - **Linux**: `~/.local/share/Claude/logs/mcp*.log`
124 |
125 | ### Document Processing Issues
126 | 1. **Documents not found**:
127 | ```bash
128 | # Verify directory exists and contains PDFs
129 | ls -la ~/Documents/rag/
130 | file ~/Documents/rag/*.pdf
131 | ```
132 |
133 | 2. **PDF processing failures**:
134 | ```bash
135 | # Test poppler installation
136 | pdftotext --version
137 |
138 | # Test PDF text extraction manually
139 | pdftotext ~/Documents/rag/sample.pdf -
140 | ```
141 |
142 | 3. **Empty search results**:
143 | - Check if documents were successfully indexed
144 | - Verify Ollama is running (`ollama serve`)
145 | - Check embedding model is installed (`ollama list`)
146 |
147 | ### Log Analysis
148 | ```bash
149 | # View real-time logs
150 | tail -f /tmp/rust-local-rag/rust-local-rag.log
151 |
152 | # Search for errors
153 | grep -i "error\|failed\|panic" /tmp/rust-local-rag/rust-local-rag.log
154 |
155 | # Check document loading
156 | grep -i "document" /tmp/rust-local-rag/rust-local-rag.log
157 | ```
158 |
159 | ### Configuration Validation
160 | ```bash
161 | # Test configuration with manual run
162 | DATA_DIR="~/Documents/data" \
163 | DOCUMENTS_DIR="~/Documents/rag" \
164 | LOG_DIR="/tmp/rust-local-rag" \
165 | LOG_LEVEL="debug" \
166 | rust-local-rag
167 | ```
168 |
169 | ## Performance Optimization
170 |
171 | ### For Large Document Collections
172 | - Use SSD storage for `DATA_DIR`
173 | - Increase `LOG_MAX_MB` for detailed logging
174 | - Consider splitting large PDFs into smaller files
175 | - Monitor memory usage during initial indexing
176 |
177 | ### For Faster Searches
178 | - Keep Ollama running continuously
179 | - Use specific search terms rather than broad queries
180 | - Adjust `top_k` parameter based on needs (lower = faster)
181 |
182 | ## Security Considerations
183 |
184 | - Documents are processed locally (no external API calls)
185 | - Embeddings stored locally in `DATA_DIR`
186 | - Ollama runs locally for embedding generation
187 | - No document content sent to external services
188 |
```
--------------------------------------------------------------------------------
/src/rag_engine.rs:
--------------------------------------------------------------------------------
```rust
1 | use anyhow::Result;
2 | use serde::{Deserialize, Serialize};
3 | use std::collections::HashMap;
4 | use uuid::Uuid;
5 | use walkdir::WalkDir;
6 |
7 | use crate::embeddings::EmbeddingService;
8 |
9 | #[derive(Debug, Clone, Serialize, Deserialize)]
10 | pub struct DocumentChunk {
11 | pub id: String,
12 | pub document_name: String,
13 | pub text: String,
14 | pub embedding: Vec<f32>,
15 | pub chunk_index: usize,
16 | }
17 |
18 | #[derive(Debug, Clone)]
19 | pub struct SearchResult {
20 | pub text: String,
21 | pub score: f32,
22 | pub document: String,
23 | pub chunk_id: String,
24 | }
25 |
26 | pub struct RagEngine {
27 | chunks: HashMap<String, DocumentChunk>,
28 | embedding_service: EmbeddingService,
29 | data_dir: String,
30 | }
31 |
32 | impl RagEngine {
33 | pub async fn new(data_dir: &str) -> Result<Self> {
34 | let embedding_service = EmbeddingService::new().await?;
35 |
36 | let mut engine = Self {
37 | chunks: HashMap::new(),
38 | embedding_service,
39 | data_dir: data_dir.to_string(),
40 | };
41 |
42 | if let Err(e) = engine.load_from_disk().await {
43 | tracing::warn!("Could not load existing data: {}", e);
44 | }
45 |
46 | Ok(engine)
47 | }
48 |
49 | pub async fn add_document(&mut self, filename: &str, data: &[u8]) -> Result<usize> {
50 | tracing::info!("Processing document: {}", filename);
51 |
52 | let text = self.extract_pdf_text(data)?;
53 | if text.trim().is_empty() {
54 | return Err(anyhow::anyhow!("No text extracted from PDF"));
55 | }
56 |
57 | let chunks = self.chunk_text(&text, 500);
58 | tracing::info!("Created {} chunks for {}", chunks.len(), filename);
59 |
60 | self.chunks
61 | .retain(|_, chunk| chunk.document_name != filename);
62 |
63 | let mut chunk_count = 0;
64 | for (i, chunk_text) in chunks.into_iter().enumerate() {
65 | if chunk_text.trim().len() < 10 {
66 | continue;
67 | }
68 |
69 | tracing::debug!("Generating embedding for chunk {} of {}", i + 1, filename);
70 | let embedding = self.embedding_service.get_embedding(&chunk_text).await?;
71 |
72 | let chunk = DocumentChunk {
73 | id: Uuid::new_v4().to_string(),
74 | document_name: filename.to_string(),
75 | text: chunk_text,
76 | embedding,
77 | chunk_index: i,
78 | };
79 |
80 | self.chunks.insert(chunk.id.clone(), chunk);
81 | chunk_count += 1;
82 | }
83 |
84 | self.save_to_disk().await?;
85 |
86 | tracing::info!(
87 | "Successfully processed {} chunks for {}",
88 | chunk_count,
89 | filename
90 | );
91 | Ok(chunk_count)
92 | }
93 |
94 | pub async fn search(&self, query: &str, top_k: usize) -> Result<Vec<SearchResult>> {
95 | if self.chunks.is_empty() {
96 | return Ok(vec![]);
97 | }
98 |
99 | tracing::debug!("Searching for: '{}'", query);
100 |
101 | let query_embedding = self.embedding_service.get_embedding(query).await?;
102 |
103 | let mut scores: Vec<(f32, &DocumentChunk)> = self
104 | .chunks
105 | .values()
106 | .map(|chunk| {
107 | let similarity = cosine_similarity(&query_embedding, &chunk.embedding);
108 | (similarity, chunk)
109 | })
110 | .collect();
111 |
112 | scores.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap());
113 |
114 | Ok(scores
115 | .into_iter()
116 | .take(top_k)
117 | .map(|(score, chunk)| SearchResult {
118 | text: chunk.text.clone(),
119 | score,
120 | document: chunk.document_name.clone(),
121 | chunk_id: chunk.id.clone(),
122 | })
123 | .collect())
124 | }
125 |
126 | pub fn list_documents(&self) -> Vec<String> {
127 | let mut docs: Vec<String> = self
128 | .chunks
129 | .values()
130 | .map(|chunk| chunk.document_name.clone())
131 | .collect::<std::collections::HashSet<_>>()
132 | .into_iter()
133 | .collect();
134 | docs.sort();
135 | docs
136 | }
137 |
138 | pub fn get_stats(&self) -> serde_json::Value {
139 | let doc_count = self.list_documents().len();
140 | let chunk_count = self.chunks.len();
141 |
142 | serde_json::json!({
143 | "documents": doc_count,
144 | "chunks": chunk_count,
145 | "status": "ready"
146 | })
147 | }
148 |
149 | pub async fn load_documents_from_dir(&mut self, dir: &str) -> Result<()> {
150 | for entry in WalkDir::new(dir).into_iter().filter_map(|e| e.ok()) {
151 | let path = entry.path();
152 | if path.extension().and_then(|s| s.to_str()) == Some("pdf") {
153 | let filename = path.file_name().unwrap().to_str().unwrap();
154 |
155 | if self.chunks.values().any(|c| c.document_name == filename) {
156 | tracing::info!("Document {} already processed, skipping", filename);
157 | continue;
158 | }
159 |
160 | match tokio::fs::read(&path).await {
161 | Ok(data) => {
162 | tracing::info!("Loading document: {}", filename);
163 | match self.add_document(filename, &data).await {
164 | Ok(chunk_count) => {
165 | tracing::info!(
166 | "Successfully processed {} with {} chunks",
167 | filename,
168 | chunk_count
169 | );
170 | }
171 | Err(e) => {
172 | tracing::warn!("Skipping {}: {}", filename, e);
173 | }
174 | }
175 | }
176 | Err(e) => {
177 | tracing::error!("Failed to read {}: {}", filename, e);
178 | }
179 | }
180 | }
181 | }
182 |
183 | Ok(())
184 | }
185 |
186 | fn extract_pdf_text(&self, data: &[u8]) -> Result<String> {
187 | tracing::info!("Extracting PDF text using pdftotext system binary");
188 | self.extract_pdf_with_pdftotext(data)
189 | }
190 |
191 | fn extract_pdf_with_pdftotext(&self, data: &[u8]) -> Result<String> {
192 | use std::process::Command;
193 |
194 | let temp_dir = std::env::temp_dir();
195 | let temp_file = temp_dir.join(format!("temp_pdf_{}.pdf", std::process::id()));
196 |
197 | std::fs::write(&temp_file, data)
198 | .map_err(|e| anyhow::anyhow!("Failed to write temp PDF: {}", e))?;
199 |
200 | let output = Command::new("pdftotext")
201 | .arg("-layout")
202 | .arg("-enc")
203 | .arg("UTF-8")
204 | .arg(&temp_file)
205 | .arg("-")
206 | .output();
207 | let _ = std::fs::remove_file(&temp_file);
208 |
209 | match output {
210 | Ok(output) if output.status.success() => {
211 | let text = String::from_utf8_lossy(&output.stdout).to_string();
212 | let text_chars = text.chars().count();
213 |
214 | if text.trim().is_empty() {
215 | tracing::warn!("pdftotext extracted 0 characters");
216 | Err(anyhow::anyhow!("pdftotext produced no text output"))
217 | } else {
218 | tracing::info!("✅ pdftotext extracted {} characters", text_chars);
219 | Ok(text)
220 | }
221 | }
222 | Ok(output) => {
223 | let error_msg = String::from_utf8_lossy(&output.stderr);
224 | tracing::warn!("pdftotext failed with error: {}", error_msg);
225 | Err(anyhow::anyhow!("pdftotext failed: {}", error_msg))
226 | }
227 | Err(e) => {
228 | tracing::warn!("Failed to run pdftotext command: {}", e);
229 | Err(anyhow::anyhow!(
230 | "pdftotext command failed: {} (is poppler installed?)",
231 | e
232 | ))
233 | }
234 | }
235 | }
236 |
237 | fn chunk_text(&self, text: &str, chunk_size: usize) -> Vec<String> {
238 | let words: Vec<&str> = text.split_whitespace().collect();
239 | let mut chunks = Vec::new();
240 |
241 | for chunk in words.chunks(chunk_size) {
242 | let chunk_text = chunk.join(" ");
243 | if !chunk_text.trim().is_empty() {
244 | chunks.push(chunk_text);
245 | }
246 | }
247 |
248 | chunks
249 | }
250 |
251 | async fn save_to_disk(&self) -> Result<()> {
252 | let path = format!("{}/chunks.json", self.data_dir);
253 | let data = serde_json::to_string_pretty(&self.chunks)?;
254 | tokio::fs::write(path, data).await?;
255 | tracing::debug!("Saved {} chunks to disk", self.chunks.len());
256 | Ok(())
257 | }
258 |
259 | async fn load_from_disk(&mut self) -> Result<()> {
260 | let path = format!("{}/chunks.json", self.data_dir);
261 | if tokio::fs::try_exists(&path).await? {
262 | let data = tokio::fs::read_to_string(path).await?;
263 | self.chunks = serde_json::from_str(&data)?;
264 | tracing::info!("Loaded {} chunks from disk", self.chunks.len());
265 | }
266 | Ok(())
267 | }
268 | }
269 |
270 | fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
271 | if a.len() != b.len() {
272 | return 0.0;
273 | }
274 |
275 | let dot_product: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
276 | let norm_a: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
277 | let norm_b: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
278 |
279 | if norm_a == 0.0 || norm_b == 0.0 {
280 | 0.0
281 | } else {
282 | dot_product / (norm_a * norm_b)
283 | }
284 | }
285 |
```