feat: resumable sha256 embedding cache + cost estimate
This commit is contained in:
+84
-1
@@ -1 +1,84 @@
|
||||
// implemented in a later task
|
||||
use crate::ionos::IonosClient;
|
||||
use crate::model::AppError;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub struct EmbeddingStore {
|
||||
dir: PathBuf, // <index_dir>/embeddings/<model-sanitized>/
|
||||
model: String,
|
||||
}
|
||||
|
||||
pub struct Estimate { pub items: usize, pub chars: usize }
|
||||
|
||||
pub fn estimate(pending: &[String]) -> Estimate {
|
||||
Estimate { items: pending.len(), chars: pending.iter().map(|s| s.len()).sum() }
|
||||
}
|
||||
|
||||
impl EmbeddingStore {
|
||||
pub fn key(model: &str, text: &str) -> String {
|
||||
let mut h = Sha256::new();
|
||||
h.update(model.as_bytes());
|
||||
h.update([0u8]);
|
||||
h.update(text.as_bytes());
|
||||
hex(&h.finalize())
|
||||
}
|
||||
|
||||
pub fn open(index_dir: &str, model: &str) -> Result<Self, AppError> {
|
||||
let safe = model.replace('/', "_");
|
||||
let dir = PathBuf::from(index_dir).join("embeddings").join(safe);
|
||||
fs::create_dir_all(&dir)
|
||||
.map_err(|e| AppError::Io(format!("{}: {e}", dir.display())))?;
|
||||
Ok(Self { dir, model: model.to_string() })
|
||||
}
|
||||
|
||||
fn path(&self, text: &str) -> PathBuf {
|
||||
self.dir.join(format!("{}.f32", Self::key(&self.model, text)))
|
||||
}
|
||||
|
||||
pub fn has(&self, text: &str) -> bool { self.path(text).exists() }
|
||||
|
||||
pub fn get(&self, text: &str) -> Option<Vec<f32>> {
|
||||
let bytes = fs::read(self.path(text)).ok()?;
|
||||
Some(bytes.chunks_exact(4)
|
||||
.map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Atomic write (temp + rename) so an interrupted run never leaves
|
||||
/// a half-written vector — the next run simply re-embeds that key.
|
||||
pub fn put(&self, text: &str, v: &[f32]) -> Result<(), AppError> {
|
||||
let mut bytes = Vec::with_capacity(v.len() * 4);
|
||||
for f in v { bytes.extend_from_slice(&f.to_le_bytes()); }
|
||||
let final_path = self.path(text);
|
||||
let tmp = final_path.with_extension("tmp");
|
||||
fs::write(&tmp, &bytes).map_err(|e| AppError::Io(format!("{e}")))?;
|
||||
fs::rename(&tmp, &final_path).map_err(|e| AppError::Io(format!("{e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn hex(b: &[u8]) -> String {
|
||||
b.iter().map(|x| format!("{:02x}", x)).collect()
|
||||
}
|
||||
|
||||
/// Embed a batch via IONOS, persisting each result. Only `texts` not
|
||||
/// already cached should be passed (caller filters via `has`).
|
||||
pub fn embed_batch(
|
||||
client: &IonosClient, store: &EmbeddingStore, model: &str, texts: &[String],
|
||||
) -> Result<usize, AppError> {
|
||||
if texts.is_empty() { return Ok(0); }
|
||||
let body = serde_json::json!({ "model": model, "input": texts });
|
||||
let resp: serde_json::Value = client.post_json("/embeddings", &body)?;
|
||||
let data = resp["data"].as_array()
|
||||
.ok_or_else(|| AppError::Ionos("embeddings: no data[]".into()))?;
|
||||
let mut n = 0;
|
||||
for (i, item) in data.iter().enumerate() {
|
||||
let v: Vec<f32> = item["embedding"].as_array()
|
||||
.ok_or_else(|| AppError::Ionos("embeddings: no embedding[]".into()))?
|
||||
.iter().map(|x| x.as_f64().unwrap_or(0.0) as f32).collect();
|
||||
store.put(&texts[i], &v)?;
|
||||
n += 1;
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user