Skip to content

Commit 72a6c95

Browse files
added caching
1 parent 4347162 commit 72a6c95

File tree

8 files changed

+334
-24
lines changed

8 files changed

+334
-24
lines changed

Cargo.lock

Lines changed: 79 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

kb_core/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,5 @@ syntect-assets = "0.23.6"
2525
lazy_static = "1.5.0"
2626
bat = { version = "0.25.0", features = ["build-assets"] }
2727
ignore = "0.4.23"
28+
sha2 = "0.10.9"
29+
hex = "0.4.3"

kb_core/src/chroma/mod.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,3 +159,27 @@ pub async fn query_chroma(
159159

160160
Ok(parsed)
161161
}
162+
163+
pub async fn delete_chunk(client: &Client, id: &str) -> anyhow::Result<()> {
164+
let config = config::load_config()?;
165+
let collection_id = get_collection_id(client).await?;
166+
167+
let url = format!(
168+
"{}/api/v2/tenants/{}/databases/{}/collections/{}/delete",
169+
config.chroma_host, TENANT, DATABASE, collection_id
170+
);
171+
172+
let payload = serde_json::json!({
173+
"ids": [id]
174+
});
175+
176+
let resp = client.post(&url).json(&payload).send().await?;
177+
let status = resp.status();
178+
let body = resp.text().await?;
179+
180+
if !status.is_success() {
181+
anyhow::bail!("Failed to delete chunk {}: HTTP {} - {}", id, status, body);
182+
}
183+
184+
Ok(())
185+
}

kb_core/src/cli/commands.rs

Lines changed: 54 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ use crate::config;
33
use crate::embedding;
44
use crate::llm;
55
use crate::utils;
6+
use std::time::UNIX_EPOCH;
67
use indicatif::{ProgressBar, ProgressStyle};
78
use reqwest::Client;
89
use std::fs;
@@ -56,7 +57,7 @@ pub fn handle_config(set_api_key: Option<String>, show: bool) -> anyhow::Result<
5657
}
5758

5859
pub async fn handle_index(client: &Client, path: &Path) -> anyhow::Result<()> {
59-
let paths = utils::collect_files(path)?;
60+
let paths = crate::utils::collect_files(path)?;
6061
let total_files = paths.len() as u64;
6162
let pb = ProgressBar::new(total_files);
6263
pb.set_style(
@@ -65,23 +66,69 @@ pub async fn handle_index(client: &Client, path: &Path) -> anyhow::Result<()> {
6566
.tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏ "),
6667
);
6768

69+
let config_dir = crate::config::get_config_dir()?;
70+
let mut state = crate::state::IndexState::load(&config_dir)?;
71+
6872
for path in paths {
6973
pb.set_message(format!("Indexing {}", path.display()));
74+
let metadata = fs::metadata(&path)?;
75+
let modified = metadata.modified()?.duration_since(UNIX_EPOCH)?.as_secs();
76+
let file_str = path.to_string_lossy().to_string();
77+
78+
// Skip if the file hasn't changed
79+
if let Some(prev) = state.get_last_modified(&file_str) {
80+
if prev == modified {
81+
pb.inc(1);
82+
continue;
83+
}
84+
}
85+
7086
let content = fs::read_to_string(&path)?;
71-
let chunks = utils::chunk_text(&content);
87+
let chunks = crate::utils::chunk_text(&content);
88+
let prev_chunks = state.get_file_chunks(&file_str).cloned().unwrap_or_default();
89+
let mut new_chunks = vec![];
7290

7391
for chunk in &chunks {
7492
if chunk.trim().is_empty() || chunk.len() > 100_000 {
7593
continue;
7694
}
7795

96+
let hash = crate::state::IndexState::hash_chunk(chunk);
97+
if crate::state::IndexState::has_chunk(&prev_chunks, &hash) {
98+
continue;
99+
}
100+
78101
let id = Uuid::new_v4().to_string();
79-
let embedding = embedding::get_embedding(&client, &chunk).await?;
80-
chroma::send_to_chroma(&client, &id, &chunk, &embedding, &path, &pb).await?;
102+
let embedding = crate::embedding::get_embedding(client, chunk).await?;
103+
crate::chroma::send_to_chroma(client, &id, chunk, &embedding, &path, &pb).await?;
104+
105+
new_chunks.push(crate::state::IndexedChunk { id, hash });
81106
}
107+
108+
if !new_chunks.is_empty() {
109+
let mut updated_chunks = prev_chunks.clone();
110+
let mut removed_chunks = vec![];
111+
112+
updated_chunks.retain(|c| {
113+
let keep = new_chunks.iter().all(|n| n.hash != c.hash);
114+
if !keep {
115+
removed_chunks.push(c.clone());
116+
}
117+
keep
118+
});
119+
120+
updated_chunks.extend(new_chunks);
121+
state.update_file_chunks(&file_str, updated_chunks, modified);
122+
123+
for chunk in removed_chunks {
124+
crate::chroma::delete_chunk(client, &chunk.id).await?;
125+
}
126+
}
127+
82128
pb.inc(1);
83129
}
84130

131+
state.save(&config_dir)?;
85132
pb.finish_with_message("🎉 Indexing complete.");
86133
Ok(())
87134
}
@@ -149,7 +196,7 @@ pub async fn handle_query(
149196
}
150197
}
151198
"smart" => {
152-
let annotated_chunks: Vec<String> = results.iter()
199+
let context_chunks: Vec<String> = results.iter()
153200
.map(|r| {
154201
let lang = Path::new(r.source)
155202
.extension()
@@ -163,20 +210,9 @@ pub async fn handle_query(
163210
})
164211
.collect();
165212

166-
let context = annotated_chunks.join("\n\n---\n\n");
167-
168-
let prompt = format!(
169-
"You are a helpful coding and personal assistant.\n\
170-
Use the following code snippets to answer the question. Each file is shown with its source path and syntax.\n\
171-
Format your response in Markdown and include code where necessary.\n\n\
172-
Question:\n{}\n\n\
173-
Documents:\n{}\n\n\
174-
Answer:",
175-
query, context
176-
);
177-
178-
let raw_answer = llm::get_llm_response(client, &prompt).await?;
213+
let raw_answer = llm::get_llm_response(client, query, &context_chunks).await?;
179214
let rendered = utils::render_markdown_highlighted(&raw_answer);
215+
180216
println!("💡 Answer:\n\n{}", rendered);
181217
}
182218
_ => {

kb_core/src/config/mod.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ use dirs::config_dir;
22
use serde::{Deserialize, Serialize};
33
use std::env;
44
use std::fs;
5+
use std::path::PathBuf;
56

67
#[derive(Deserialize, Serialize, Clone)]
78
pub struct AppConfig {
@@ -81,3 +82,12 @@ pub fn get_openai_api_key() -> anyhow::Result<String> {
8182

8283
anyhow::bail!("OpenAI API key not found in environment or config file. Please set the OPENAI_API_KEY environment variable or add it to your config file.")
8384
}
85+
86+
pub fn get_config_dir() -> anyhow::Result<PathBuf> {
87+
let path = config_dir()
88+
.ok_or_else(|| anyhow::anyhow!("Unable to determine config directory"))?
89+
.join("kb-index");
90+
91+
std::fs::create_dir_all(&path)?;
92+
Ok(path)
93+
}

kb_core/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ pub mod config;
44
pub mod embedding;
55
pub mod utils;
66
pub mod llm;
7+
pub mod state;

kb_core/src/llm/mod.rs

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,41 @@
1-
use reqwest::Client;
21
use crate::config;
2+
use crate::state::{QueryState, hash_query_context};
3+
use reqwest::Client;
34

4-
pub async fn get_llm_response(client: &Client, prompt: &str) -> anyhow::Result<String> {
5+
pub async fn get_llm_response(
6+
client: &Client,
7+
prompt: &str,
8+
context_chunks: &[String],
9+
) -> anyhow::Result<String> {
510
let api_key = config::get_openai_api_key()?;
6-
let config = config::load_config()?;
11+
let cfg = config::load_config()?;
12+
let config_dir = config::get_config_dir()?;
13+
let mut state = QueryState::load(&config_dir)?;
14+
15+
// Generate context hash from context chunks
16+
let context_hash = hash_query_context(prompt, context_chunks);
17+
18+
// Return cached answer if it exists
19+
if let Some(cached) = state.get_cached_answer(prompt, &context_hash) {
20+
return Ok(cached);
21+
}
22+
23+
// Concatenate all context chunks for the prompt
24+
let full_context = context_chunks.join("\n\n---\n\n");
25+
26+
let full_prompt = format!(
27+
"You are an expert personal and code assistant.\n\
28+
Use the following code snippets to answer the question. \
29+
Format your response in Markdown and include code where necessary.\n\n\
30+
Question:\n{}\n\nContext:\n{}\n\nAnswer:",
31+
prompt, full_context
32+
);
733

834
let body = serde_json::json!({
9-
"model": config.openai_completion_model,
35+
"model": cfg.openai_completion_model,
1036
"messages": [
11-
{ "role": "system", "content": "You are a expert personal and code assistant." },
12-
{ "role": "user", "content": prompt }
37+
{ "role": "system", "content": "You are an expert personal and code assistant." },
38+
{ "role": "user", "content": full_prompt }
1339
],
1440
"temperature": 0.4
1541
});
@@ -29,5 +55,9 @@ pub async fn get_llm_response(client: &Client, prompt: &str) -> anyhow::Result<S
2955
.unwrap_or("No answer generated")
3056
.to_string();
3157

58+
// Cache the answer
59+
state.insert_answer(prompt.to_string(), context_hash, answer.clone());
60+
state.save(&config_dir)?;
61+
3262
Ok(answer)
3363
}

0 commit comments

Comments
 (0)