#
tokens: 11059/50000 14/14 files
lines: on (toggle) GitHub
raw markdown copy reset
# 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 | 
```