Skip to main content

sqlite_graphrag/storage/
chunks.rs

1//! Chunk storage CRUD for multi-chunk memories.
2//!
3//! Manages the `memory_chunks` table: insert embeddings for bodies that
4//! exceed the 512-token E5 limit and query chunks for vector search.
5
6// src/storage/chunks.rs
7// Chunk storage for bodies exceeding 512 tokens E5 limit
8
9use crate::embedder::f32_to_bytes;
10use crate::errors::AppError;
11use rusqlite::{params, Connection};
12
13/// Chunk.
14#[derive(Debug, Clone)]
15pub struct Chunk {
16    /// Memory identifier.
17    pub memory_id: i64,
18    /// Chunk idx.
19    pub chunk_idx: i32,
20    /// Chunk text.
21    pub chunk_text: String,
22    /// Start offset.
23    pub start_offset: i32,
24    /// End offset.
25    pub end_offset: i32,
26    /// Token count.
27    pub token_count: i32,
28}
29
30/// Insert chunks.
31pub 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
49/// Insert chunk slices.
50pub 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
73/// Upsert chunk VEC.
74pub 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    // v1.1.1 (P1): skip empty vectors so the chunk stays visible to the
82    // re-embed backfill scanner instead of persisting a vector-less row.
83    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
108/// Delete chunks.
109pub 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
117/// GAP-SG-40: counts the rows actually persisted in `memory_chunks` for a
118/// memory. Single-chunk bodies are stored inline in the `memories` row and
119/// append no chunk rows, so this returns `0` for them; multi-chunk bodies
120/// return the exact number of persisted chunk rows. Callers query this AFTER
121/// the transaction commits so the reported `chunks_persisted` reflects the
122/// observable database state rather than a pre-commit estimate.
123pub 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
132/// KNN search chunks.
133pub 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    // v1.0.76: full table scan + in-process cosine similarity. The
147    // `chunk_embeddings` table no longer has a `distance` column;
148    // similarity is computed in Rust.
149    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
174/// Get chunks by memory.
175pub 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    // GAP-SG-40: count_for_memory reports the real persisted chunk-row count.
274    #[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        // No chunk rows yet (single-chunk bodies live inline) → 0.
280        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        // Unknown memory id → 0.
304        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    // v1.1.1 (P1): an empty embedding must NOT create a chunk vector row, so
430    // the chunk stays visible to `enrich re-embed --target chunks`.
431    #[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}