1use crate::embedder::f32_to_bytes;
10use crate::errors::AppError;
11use rusqlite::{params, Connection};
12
13#[derive(Debug, Clone)]
15pub struct Chunk {
16 pub memory_id: i64,
18 pub chunk_idx: i32,
20 pub chunk_text: String,
22 pub start_offset: i32,
24 pub end_offset: i32,
26 pub token_count: i32,
28}
29
30pub fn insert_chunks(conn: &Connection, chunks: &[Chunk]) -> Result<(), AppError> {
32 for chunk in chunks {
33 conn.execute(
34 "INSERT INTO memory_chunks (memory_id, chunk_idx, chunk_text, start_offset, end_offset, token_count)
35 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
36 params![
37 chunk.memory_id,
38 chunk.chunk_idx,
39 chunk.chunk_text,
40 chunk.start_offset,
41 chunk.end_offset,
42 chunk.token_count,
43 ],
44 )?;
45 }
46 Ok(())
47}
48
49pub fn insert_chunk_slices(
51 conn: &Connection,
52 memory_id: i64,
53 body: &str,
54 chunks: &[crate::chunking::Chunk],
55) -> Result<(), AppError> {
56 for (chunk_idx, chunk) in chunks.iter().enumerate() {
57 conn.execute(
58 "INSERT INTO memory_chunks (memory_id, chunk_idx, chunk_text, start_offset, end_offset, token_count)
59 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
60 params![
61 memory_id,
62 chunk_idx as i32,
63 crate::chunking::chunk_text(body, chunk),
64 chunk.start_offset as i32,
65 chunk.end_offset as i32,
66 chunk.token_count_approx as i32,
67 ],
68 )?;
69 }
70 Ok(())
71}
72
73pub fn upsert_chunk_vec(
75 conn: &Connection,
76 _rowid: i64,
77 memory_id: i64,
78 chunk_idx: i32,
79 embedding: &[f32],
80) -> Result<(), AppError> {
81 if embedding.is_empty() {
84 tracing::debug!(
85 memory_id,
86 chunk_idx,
87 "empty chunk embedding: skipping chunk_embeddings row (backfill via enrich re-embed --target chunks)"
88 );
89 return Ok(());
90 }
91 conn.execute(
92 "INSERT OR REPLACE INTO chunk_embeddings(chunk_id, memory_id, embedding, source, model, dim)
93 VALUES (
94 (SELECT id FROM memory_chunks WHERE memory_id = ?1 AND chunk_idx = ?2),
95 ?1, ?3, 'llm-headless', ?4, ?5
96 )",
97 params![
98 memory_id,
99 chunk_idx,
100 f32_to_bytes(embedding),
101 crate::constants::SQLITE_GRAPHRAG_VERSION,
102 crate::constants::embedding_dim() as i64,
103 ],
104 )?;
105 Ok(())
106}
107
108pub fn delete_chunks(conn: &Connection, memory_id: i64) -> Result<(), AppError> {
110 conn.execute(
111 "DELETE FROM memory_chunks WHERE memory_id = ?1",
112 params![memory_id],
113 )?;
114 Ok(())
115}
116
117pub fn count_for_memory(conn: &Connection, memory_id: i64) -> Result<usize, AppError> {
124 let n: i64 = conn.query_row(
125 "SELECT COUNT(*) FROM memory_chunks WHERE memory_id = ?1",
126 params![memory_id],
127 |r| r.get(0),
128 )?;
129 Ok(n as usize)
130}
131
132pub fn knn_search_chunks(
134 conn: &Connection,
135 embedding: &[f32],
136 k: usize,
137) -> Result<Vec<(i64, i32, f32)>, AppError> {
138 if embedding.len() != crate::constants::embedding_dim() {
139 return Err(AppError::Embedding(
140 crate::i18n::validation::embedding_knn_search_chunks_dim_mismatch(
141 embedding.len(),
142 crate::constants::embedding_dim(),
143 ),
144 ));
145 }
146 let mut stmt =
150 conn.prepare_cached("SELECT chunk_id, memory_id, embedding FROM chunk_embeddings")?;
151 let mut scored: Vec<(i64, i32, f32)> = stmt
152 .query_map([], |r| {
153 let chunk_id: i64 = r.get(0)?;
154 let memory_id: i64 = r.get(1)?;
155 let bytes: Vec<u8> = r.get(2)?;
156 Ok((chunk_id, memory_id, bytes))
157 })?
158 .filter_map(|row| {
159 row.ok().and_then(|(_, memory_id, bytes)| {
160 let stored = crate::embedder::bytes_to_f32(&bytes);
161 if stored.len() != embedding.len() {
162 return None;
163 }
164 let score = crate::similarity::cosine_similarity(embedding, &stored);
165 Some((memory_id, 0, score))
166 })
167 })
168 .collect();
169 scored.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap_or(std::cmp::Ordering::Equal));
170 scored.truncate(k);
171 Ok(scored)
172}
173
174pub fn get_chunks_by_memory(conn: &Connection, memory_id: i64) -> Result<Vec<Chunk>, AppError> {
176 let mut stmt = conn.prepare_cached(
177 "SELECT memory_id, chunk_idx, chunk_text, start_offset, end_offset, token_count
178 FROM memory_chunks WHERE memory_id = ?1 ORDER BY chunk_idx",
179 )?;
180 let rows = stmt
181 .query_map(params![memory_id], |r| {
182 Ok(Chunk {
183 memory_id: r.get(0)?,
184 chunk_idx: r.get(1)?,
185 chunk_text: r.get(2)?,
186 start_offset: r.get(3)?,
187 end_offset: r.get(4)?,
188 token_count: r.get(5)?,
189 })
190 })?
191 .collect::<Result<Vec<_>, _>>()?;
192 Ok(rows)
193}
194
195#[cfg(test)]
196mod tests {
197 use super::*;
198 use crate::constants::embedding_dim;
199 use crate::storage::connection::register_vec_extension;
200 use rusqlite::Connection;
201 use tempfile::TempDir;
202
203 fn setup_db() -> (TempDir, Connection) {
204 register_vec_extension();
205 let tmp = TempDir::new().unwrap();
206 let db_path = tmp.path().join("test.db");
207 let mut conn = Connection::open(&db_path).unwrap();
208 crate::migrations::runner().run(&mut conn).unwrap();
209 (tmp, conn)
210 }
211
212 fn insert_memory(conn: &Connection) -> i64 {
213 conn.execute(
214 "INSERT INTO memories (namespace, name, type, description, body, body_hash)
215 VALUES ('global', 'test-mem', 'user', 'desc', 'body', 'hash1')",
216 [],
217 )
218 .unwrap();
219 conn.last_insert_rowid()
220 }
221
222 #[test]
223 fn test_insert_chunks_empty_ok() {
224 let (_tmp, conn) = setup_db();
225 let resultado = insert_chunks(&conn, &[]);
226 assert!(resultado.is_ok());
227 }
228
229 #[test]
230 fn test_insert_chunks_and_get_by_memory() {
231 let (_tmp, conn) = setup_db();
232 let memory_id = insert_memory(&conn);
233
234 let chunks = vec![
235 Chunk {
236 memory_id,
237 chunk_idx: 0,
238 chunk_text: "primeiro chunk".to_string(),
239 start_offset: 0,
240 end_offset: 14,
241 token_count: 3,
242 },
243 Chunk {
244 memory_id,
245 chunk_idx: 1,
246 chunk_text: "segundo chunk".to_string(),
247 start_offset: 15,
248 end_offset: 28,
249 token_count: 3,
250 },
251 ];
252
253 insert_chunks(&conn, &chunks).unwrap();
254
255 let recuperados = get_chunks_by_memory(&conn, memory_id).unwrap();
256 assert_eq!(recuperados.len(), 2);
257 assert_eq!(recuperados[0].chunk_idx, 0);
258 assert_eq!(recuperados[0].chunk_text, "primeiro chunk");
259 assert_eq!(recuperados[0].start_offset, 0);
260 assert_eq!(recuperados[0].end_offset, 14);
261 assert_eq!(recuperados[0].token_count, 3);
262 assert_eq!(recuperados[1].chunk_idx, 1);
263 assert_eq!(recuperados[1].chunk_text, "segundo chunk");
264 }
265
266 #[test]
267 fn test_get_chunks_missing_memory_returns_empty() {
268 let (_tmp, conn) = setup_db();
269 let resultado = get_chunks_by_memory(&conn, 9999).unwrap();
270 assert!(resultado.is_empty());
271 }
272
273 #[test]
275 fn test_count_for_memory_reflects_persisted_rows() {
276 let (_tmp, conn) = setup_db();
277 let memory_id = insert_memory(&conn);
278
279 assert_eq!(count_for_memory(&conn, memory_id).unwrap(), 0);
281
282 let chunks = vec![
283 Chunk {
284 memory_id,
285 chunk_idx: 0,
286 chunk_text: "a".to_string(),
287 start_offset: 0,
288 end_offset: 1,
289 token_count: 1,
290 },
291 Chunk {
292 memory_id,
293 chunk_idx: 1,
294 chunk_text: "b".to_string(),
295 start_offset: 1,
296 end_offset: 2,
297 token_count: 1,
298 },
299 ];
300 insert_chunks(&conn, &chunks).unwrap();
301 assert_eq!(count_for_memory(&conn, memory_id).unwrap(), 2);
302
303 assert_eq!(count_for_memory(&conn, 9999).unwrap(), 0);
305 }
306
307 #[test]
308 fn test_delete_chunks_removes_all() {
309 let (_tmp, conn) = setup_db();
310 let memory_id = insert_memory(&conn);
311
312 let chunks = vec![
313 Chunk {
314 memory_id,
315 chunk_idx: 0,
316 chunk_text: "chunk a".to_string(),
317 start_offset: 0,
318 end_offset: 7,
319 token_count: 2,
320 },
321 Chunk {
322 memory_id,
323 chunk_idx: 1,
324 chunk_text: "chunk b".to_string(),
325 start_offset: 8,
326 end_offset: 15,
327 token_count: 2,
328 },
329 ];
330 insert_chunks(&conn, &chunks).unwrap();
331
332 delete_chunks(&conn, memory_id).unwrap();
333
334 let recuperados = get_chunks_by_memory(&conn, memory_id).unwrap();
335 assert!(recuperados.is_empty());
336 }
337
338 #[test]
339 fn test_delete_chunks_memory_without_chunks_ok() {
340 let (_tmp, conn) = setup_db();
341 let resultado = delete_chunks(&conn, 9999);
342 assert!(resultado.is_ok());
343 }
344
345 #[test]
346 fn test_get_chunks_ordered_by_chunk_idx() {
347 let (_tmp, conn) = setup_db();
348 let memory_id = insert_memory(&conn);
349
350 let chunks = vec![
351 Chunk {
352 memory_id,
353 chunk_idx: 2,
354 chunk_text: "terceiro".to_string(),
355 start_offset: 20,
356 end_offset: 28,
357 token_count: 1,
358 },
359 Chunk {
360 memory_id,
361 chunk_idx: 0,
362 chunk_text: "primeiro".to_string(),
363 start_offset: 0,
364 end_offset: 8,
365 token_count: 1,
366 },
367 Chunk {
368 memory_id,
369 chunk_idx: 1,
370 chunk_text: "segundo".to_string(),
371 start_offset: 9,
372 end_offset: 16,
373 token_count: 1,
374 },
375 ];
376 insert_chunks(&conn, &chunks).unwrap();
377
378 let recuperados = get_chunks_by_memory(&conn, memory_id).unwrap();
379 assert_eq!(recuperados.len(), 3);
380 assert_eq!(recuperados[0].chunk_idx, 0);
381 assert_eq!(recuperados[1].chunk_idx, 1);
382 assert_eq!(recuperados[2].chunk_idx, 2);
383 }
384
385 #[test]
386 #[serial_test::serial(env)]
387 fn test_upsert_chunk_vec_and_knn_search() {
388 let (_tmp, conn) = setup_db();
389 let memory_id = insert_memory(&conn);
390
391 let chunk = Chunk {
392 memory_id,
393 chunk_idx: 0,
394 chunk_text: "embedding test".to_string(),
395 start_offset: 0,
396 end_offset: 14,
397 token_count: 2,
398 };
399 insert_chunks(&conn, &[chunk]).unwrap();
400
401 let mut embedding = vec![0.0f32; embedding_dim()];
402 embedding[0] = 1.0;
403
404 let chunk_id: i64 = conn
405 .query_row(
406 "SELECT id FROM memory_chunks WHERE memory_id = ?1 AND chunk_idx = 0",
407 params![memory_id],
408 |r| r.get(0),
409 )
410 .unwrap();
411
412 upsert_chunk_vec(&conn, chunk_id, memory_id, 0, &embedding).unwrap();
413
414 let resultados = knn_search_chunks(&conn, &embedding, 1).unwrap();
415 assert_eq!(resultados.len(), 1);
416 assert_eq!(resultados[0].0, memory_id);
417 assert_eq!(resultados[0].1, 0);
418 }
419
420 #[test]
421 #[serial_test::serial(env)]
422 fn test_knn_search_chunks_without_data_returns_empty() {
423 let (_tmp, conn) = setup_db();
424 let embedding = vec![0.0f32; embedding_dim()];
425 let resultado = knn_search_chunks(&conn, &embedding, 5).unwrap();
426 assert!(resultado.is_empty());
427 }
428
429 #[test]
432 fn test_upsert_chunk_vec_empty_embedding_skips_row() {
433 let (_tmp, conn) = setup_db();
434 let memory_id = insert_memory(&conn);
435 let chunk = Chunk {
436 memory_id,
437 chunk_idx: 0,
438 chunk_text: "sem vetor".to_string(),
439 start_offset: 0,
440 end_offset: 9,
441 token_count: 2,
442 };
443 insert_chunks(&conn, &[chunk]).unwrap();
444
445 upsert_chunk_vec(&conn, 0, memory_id, 0, &[]).unwrap();
446
447 let count: i64 = conn
448 .query_row(
449 "SELECT COUNT(*) FROM chunk_embeddings WHERE memory_id = ?1",
450 params![memory_id],
451 |r| r.get(0),
452 )
453 .unwrap();
454 assert_eq!(count, 0, "empty embedding must not persist a chunk row");
455 }
456
457 #[test]
458 fn test_insert_chunks_invalid_fk_fails() {
459 let (_tmp, conn) = setup_db();
460 let chunk = Chunk {
461 memory_id: 99999,
462 chunk_idx: 0,
463 chunk_text: "sem pai".to_string(),
464 start_offset: 0,
465 end_offset: 7,
466 token_count: 1,
467 };
468 let resultado = insert_chunks(&conn, &[chunk]);
469 assert!(resultado.is_err());
470 }
471}