Skip to main content

sqlite_graphrag/commands/
split_body.rs

1//! Handler for the `split-body` CLI subcommand (v1.1.03, GAP-V8).
2//!
3//! Splits memories whose body exceeds a character threshold into N child
4//! memories, reusing the lossless section-based chunker
5//! [`crate::chunking::split_body_by_sections`]. The original memory is NOT
6//! soft-deleted: its `metadata` is annotated with `superseded_by_split` so
7//! history and searchability are preserved. Each child memory gets a
8//! canonical `replaces` graph relationship pointing back to the original.
9
10use crate::chunking::split_body_by_sections;
11use crate::constants::{DEFAULT_RELATION_WEIGHT, MAX_MEMORY_NAME_LEN};
12use crate::entity_type::EntityType;
13use crate::errors::AppError;
14use crate::i18n::errors_msg;
15use crate::output::{self, JsonOutputFormat};
16use crate::paths::AppPaths;
17use crate::storage::connection::open_rw;
18use crate::storage::entities::{self, NewEntity};
19use crate::storage::{memories, versions};
20use rusqlite::params;
21use serde::Serialize;
22
23/// Character threshold below which a memory is left untouched.
24pub const DEFAULT_SPLIT_THRESHOLD: usize = 25_000;
25
26#[derive(clap::Args)]
27#[command(
28    about = "Split oversized memories into N child memories at Markdown section boundaries",
29    after_long_help = "EXAMPLES:\n  \
30        # Split a single memory by name using the default 25k char threshold\n  \
31        sqlite-graphrag split-body --name big-doc\n\n  \
32        # Preview the split without writing\n  \
33        sqlite-graphrag split-body --name big-doc --dry-run\n\n  \
34        # Batch-split every oversized memory in the current namespace\n  \
35        sqlite-graphrag split-body --batch\n\n  \
36        # Batch-split with a custom threshold and namespace\n  \
37        sqlite-graphrag split-body --batch --threshold 50000 --namespace my-project\n\n  \
38        NOTE:\n  \
39            The original memory is NEVER soft-deleted. It is annotated with\n  \
40            metadata.superseded_by_split=true and metadata.split_into=[child names]\n  \
41            so its history and searchability are preserved. Each child memory\n  \
42            gets a canonical `replaces` graph relationship to the original."
43)]
44/// Split body args.
45pub struct SplitBodyArgs {
46    /// Memory name to split (single mode). Mutually exclusive with `--batch`.
47    #[arg(long, value_name = "NAME", conflicts_with = "batch")]
48    pub name: Option<String>,
49    /// Batch mode: split every active memory whose `LENGTH(body) > threshold`.
50    #[arg(long, default_value_t = false, conflicts_with = "name")]
51    pub batch: bool,
52    /// Only memories with `LENGTH(body) > threshold` are split. Default 25000 chars.
53    #[arg(long, value_name = "N", default_value_t = DEFAULT_SPLIT_THRESHOLD)]
54    pub threshold: usize,
55    #[arg(
56        long,
57        help = "Namespace (flag / XDG namespace.default / global)"
58    )]
59    /// Namespace scope.
60    pub namespace: Option<String>,
61    /// Preview the split(s) without writing. Emits the planned child names and
62    /// body lengths.
63    #[arg(long, default_value_t = false)]
64    pub dry_run: bool,
65    /// Output format.
66    #[arg(long, value_enum, default_value_t = JsonOutputFormat::Json)]
67    pub format: JsonOutputFormat,
68    /// Emit machine-readable JSON on stdout.
69    #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
70    pub json: bool,
71    /// Path to the SQLite database file.
72    #[arg(long)]
73    pub db: Option<String>,
74}
75
76#[derive(Serialize)]
77struct ChildPlan {
78    name: String,
79    body_len: usize,
80}
81
82#[derive(Serialize)]
83struct SplitResult {
84    original: String,
85    #[serde(skip_serializing_if = "Option::is_none")]
86    original_id: Option<i64>,
87    children: Vec<ChildPlan>,
88    threshold: usize,
89    dry_run: bool,
90    /// Total execution time in milliseconds.
91    elapsed_ms: u64,
92}
93
94#[derive(Serialize)]
95struct BatchResult {
96    namespace: String,
97    threshold: usize,
98    dry_run: bool,
99    split: Vec<SplitResult>,
100    skipped: usize,
101    /// Total execution time in milliseconds.
102    elapsed_ms: u64,
103}
104
105/// Validates the parsed args and dispatches to single or batch mode.
106pub fn run(args: SplitBodyArgs) -> Result<(), AppError> {
107    let inicio = std::time::Instant::now();
108    let _ = args.format;
109
110    if !args.batch && args.name.is_none() {
111        return Err(AppError::Validation(
112            "either --name <NAME> or --batch is required".to_string(),
113        ));
114    }
115    if args.threshold == 0 {
116        return Err(AppError::Validation(
117            "--threshold must be greater than zero".to_string(),
118        ));
119    }
120
121    let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
122    let paths = AppPaths::resolve(args.db.as_deref())?;
123    crate::storage::connection::ensure_db_ready(&paths)?;
124
125    if args.batch {
126        run_batch(&paths.db, &namespace, args.threshold, args.dry_run, inicio)
127    } else if let Some(name) = args.name.as_deref() {
128        let result = split_single(&paths.db, &namespace, name, args.threshold, args.dry_run)?;
129        output::emit_json(&result)?;
130        Ok(())
131    } else {
132        // Unreachable: validated at the top of run().
133        Err(AppError::Validation(
134            "either --name <NAME> or --batch is required".to_string(),
135        ))
136    }
137}
138
139fn run_batch(
140    db_path: &std::path::Path,
141    namespace: &str,
142    threshold: usize,
143    dry_run: bool,
144    inicio: std::time::Instant,
145) -> Result<(), AppError> {
146    let conn = open_rw(db_path)?;
147    let mut stmt =
148        conn.prepare("SELECT name FROM memories WHERE namespace = ?1 AND deleted_at IS NULL AND LENGTH(body) > ?2")?;
149    let mut rows = stmt.query(params![namespace, threshold as i64])?;
150    let mut names: Vec<String> = Vec::new();
151    while let Some(row) = rows.next()? {
152        names.push(row.get::<_, String>(0)?);
153    }
154    drop(rows);
155    drop(stmt);
156    drop(conn);
157
158    let mut split: Vec<SplitResult> = Vec::new();
159    let mut skipped = 0usize;
160    for name in &names {
161        match split_single(db_path, namespace, name, threshold, dry_run) {
162            Ok(r) => split.push(r),
163            Err(AppError::NotFound(_)) => {
164                // Race: memory was removed between scan and split. Skip quietly.
165                skipped += 1;
166            }
167            Err(e) => return Err(e),
168        }
169    }
170
171    output::emit_json(&BatchResult {
172        namespace: namespace.to_string(),
173        threshold,
174        dry_run,
175        split,
176        skipped,
177        elapsed_ms: inicio.elapsed().as_millis() as u64,
178    })?;
179    Ok(())
180}
181
182/// Splits a single memory by name into N child memories.
183///
184/// Reuses the lossless [`split_body_by_sections`] chunker so concatenating
185/// every child body reproduces the original body. The original memory is kept
186/// active and its `metadata` is annotated with `superseded_by_split` and the
187/// list of child names. Each child is inserted as a new memory (auto-embedded
188/// downstream by the remember path or `enrich --operation re-embed`) and linked
189/// to the original entity via a canonical `replaces` relationship.
190fn split_single(
191    db_path: &std::path::Path,
192    namespace: &str,
193    name: &str,
194    threshold: usize,
195    dry_run: bool,
196) -> Result<SplitResult, AppError> {
197    let start = std::time::Instant::now();
198
199    let mut conn = open_rw(db_path)?;
200    let row = memories::read_by_name(&conn, namespace, name)?
201        .ok_or_else(|| AppError::NotFound(errors_msg::memory_not_found(name, namespace)))?;
202
203    if row.body.len() <= threshold {
204        return Ok(SplitResult {
205            original: name.to_string(),
206            original_id: Some(row.id),
207            children: Vec::new(),
208            threshold,
209            dry_run,
210            elapsed_ms: start.elapsed().as_millis() as u64,
211        });
212    }
213
214    let partitions = split_body_by_sections(&row.body);
215    if partitions.len() < 2 {
216        // Body crosses the threshold but the chunker kept it as a single
217        // partition (e.g. under byte/chunk/token budgets). Nothing to split.
218        return Ok(SplitResult {
219            original: name.to_string(),
220            original_id: Some(row.id),
221            children: Vec::new(),
222            threshold,
223            dry_run,
224            elapsed_ms: start.elapsed().as_millis() as u64,
225        });
226    }
227
228    // Plan child names first so we can surface them in dry-run and metadata.
229    let child_names: Vec<String> = (0..partitions.len())
230        .map(|i| format!("{name}-part-{i}"))
231        .collect();
232
233    for child in &child_names {
234        validate_child_name(child, name)?;
235    }
236
237    let children_plan: Vec<ChildPlan> = partitions
238        .iter()
239        .zip(child_names.iter())
240        .map(|(body, n)| ChildPlan {
241            name: n.clone(),
242            body_len: body.len(),
243        })
244        .collect();
245
246    if dry_run {
247        return Ok(SplitResult {
248            original: name.to_string(),
249            original_id: Some(row.id),
250            children: children_plan,
251            threshold,
252            dry_run: true,
253            elapsed_ms: start.elapsed().as_millis() as u64,
254        });
255    }
256
257    // Parse existing metadata (stored as TEXT JSON) and merge our markers.
258    let mut metadata: serde_json::Value =
259        serde_json::from_str(&row.metadata).unwrap_or_else(|_| serde_json::json!({}));
260    let map = match metadata.as_object_mut() {
261        Some(m) => m,
262        None => {
263            return Err(AppError::Internal(anyhow::anyhow!(
264                "metadata for memory '{name}' is not a JSON object"
265            )));
266        }
267    };
268    map.insert("superseded_by_split".to_string(), serde_json::json!(true));
269    map.insert(
270        "split_into".to_string(),
271        serde_json::to_value(&child_names)?,
272    );
273    let metadata_str = serde_json::to_string(&metadata)?;
274
275    let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
276
277    // Insert each child as a new memory of type `document` (auto-embedded by
278    // downstream paths). We do NOT call the embedding pipeline here to keep
279    // the split atomic and side-effect-free; re-embed can be run separately.
280    for (body_part, child_name) in partitions.iter().zip(child_names.iter()) {
281        let child_hash = blake3_hash(body_part);
282        let new_memory = memories::NewMemory {
283            namespace: namespace.to_string(),
284            name: child_name.clone(),
285            memory_type: "document".to_string(),
286            description: format!("Partição de '{name}' (split-body)"),
287            body: body_part.clone(),
288            body_hash: child_hash,
289            session_id: row.session_id.clone(),
290            source: "system".to_string(),
291            metadata: serde_json::json!({ "split_from": name }),
292        };
293        memories::insert(&tx, &new_memory)?;
294    }
295
296    // Annotate the original memory metadata (no soft-delete: history preserved).
297    let affected = tx.execute(
298        "UPDATE memories SET metadata = ?2 WHERE id = ?1 AND deleted_at IS NULL",
299        params![row.id, metadata_str],
300    )?;
301    if affected == 0 {
302        return Err(AppError::Conflict(format!(
303            "memory '{name}' was modified concurrently; retry"
304        )));
305    }
306
307    // Record a version snapshot of the metadata annotation.
308    let next_v = versions::next_version(&tx, row.id)?;
309    versions::insert_version(
310        &tx,
311        row.id,
312        next_v,
313        &row.name,
314        &row.memory_type,
315        &row.description,
316        &row.body,
317        &metadata_str,
318        None,
319        "edit",
320    )?;
321
322    // Graph: each child entity `replaces` the original. Auto-create both
323    // endpoints so split-body is self-contained (no dependency on prior NER).
324    let original_entity_name = crate::parsers::normalize_entity_name(name);
325    let original_entity = NewEntity {
326        name: original_entity_name.clone(),
327        entity_type: EntityType::Memory,
328        description: None,
329    };
330    let original_entity_id = entities::upsert_entity(&tx, namespace, &original_entity)?;
331
332    for child_name in &child_names {
333        let child_entity_name = crate::parsers::normalize_entity_name(child_name);
334        let child_entity = NewEntity {
335            name: child_entity_name.clone(),
336            entity_type: EntityType::Memory,
337            description: None,
338        };
339        let child_entity_id = entities::upsert_entity(&tx, namespace, &child_entity)?;
340        let (_, was_created) = entities::create_or_fetch_relationship(
341            &tx,
342            namespace,
343            child_entity_id,
344            original_entity_id,
345            "replaces",
346            DEFAULT_RELATION_WEIGHT,
347            None,
348        )?;
349        if was_created {
350            entities::recalculate_degree(&tx, child_entity_id)?;
351            entities::recalculate_degree(&tx, original_entity_id)?;
352        }
353    }
354
355    tx.commit()?;
356    conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
357
358    Ok(SplitResult {
359        original: name.to_string(),
360        original_id: Some(row.id),
361        children: children_plan,
362        threshold,
363        dry_run: false,
364        elapsed_ms: start.elapsed().as_millis() as u64,
365    })
366}
367
368fn validate_child_name(child: &str, parent: &str) -> Result<(), AppError> {
369    if child.is_empty() || child.len() > MAX_MEMORY_NAME_LEN {
370        return Err(AppError::Validation(crate::i18n::validation::child_name_exceeds_max(child, parent, MAX_MEMORY_NAME_LEN)));
371    }
372    let slug_re = crate::constants::name_slug_regex();
373    if !slug_re.is_match(child) {
374        return Err(AppError::Validation(crate::i18n::validation::child_name_not_kebab(child)));
375    }
376    Ok(())
377}
378
379/// Computes the BLAKE3 hex digest of `body`, matching the project convention.
380fn blake3_hash(body: &str) -> String {
381    use blake3::Hasher;
382    let mut hasher = Hasher::new();
383    hasher.update(body.as_bytes());
384    hasher.finalize().to_hex().to_string()
385}
386
387#[cfg(test)]
388mod tests {
389    use super::*;
390    use crate::storage::memories::{insert, NewMemory};
391    use rusqlite::Connection;
392    use tempfile::TempDir;
393
394    /// `split_single` needs a `db_path` on disk so it can reopen a connection
395    /// for the transaction. This helper inserts the oversized memory and returns
396    /// the temp dir + db path.
397    fn setup_with_memory(name: &str, body_len: usize) -> (TempDir, std::path::PathBuf) {
398        crate::storage::connection::register_vec_extension();
399        let dir = TempDir::new().unwrap();
400        let db_path = dir.path().join("test.db");
401        let mut conn = Connection::open(&db_path).unwrap();
402        crate::migrations::runner().run(&mut conn).unwrap();
403        let body = body_with_headers(body_len);
404        insert(
405            &conn,
406            &NewMemory {
407                namespace: "global".to_string(),
408                name: name.to_string(),
409                memory_type: "document".to_string(),
410                description: "desc".to_string(),
411                body,
412                body_hash: format!("hash-{name}"),
413                session_id: None,
414                source: "agent".to_string(),
415                metadata: serde_json::json!({}),
416            },
417        )
418        .unwrap();
419        drop(conn);
420        (dir, db_path)
421    }
422
423    /// Builds a Markdown body with many ATX sections so the section splitter
424    /// produces multiple partitions well above the 80 KiB byte budget.
425    fn body_with_headers(total_bytes: usize) -> String {
426        let mut body = String::new();
427        let mut i = 0;
428        while body.len() < total_bytes {
429            body.push_str(&format!(
430                "# Section {i}\n\n{}\n\n",
431                "body content text here. ".repeat(200)
432            ));
433            i += 1;
434        }
435        body
436    }
437
438    #[test]
439    fn split_body_divides_long_memory_into_parts() {
440        let (_dir, db_path) = setup_with_memory("big-doc", 100_000);
441        let result = split_single(&db_path, "global", "big-doc", 25_000, false).unwrap();
442        assert!(
443            result.children.len() >= 2,
444            "expected 2+ children, got {}",
445            result.children.len()
446        );
447        // Verify each child memory was actually persisted.
448        let conn = Connection::open(&db_path).unwrap();
449        for child in &result.children {
450            let row = crate::storage::memories::read_by_name(&conn, "global", &child.name)
451                .unwrap()
452                .expect("child memory should exist");
453            assert_eq!(row.memory_type, "document");
454            assert!(
455                row.body.len() <= crate::constants::AUTOSPLIT_PARTITION_MAX_BYTES,
456                "child body {} exceeds partition budget",
457                row.body.len()
458            );
459        }
460    }
461
462    #[test]
463    fn split_body_marks_original_as_superseded() {
464        let (_dir, db_path) = setup_with_memory("super-test", 100_000);
465        split_single(&db_path, "global", "super-test", 25_000, false).unwrap();
466
467        let conn = Connection::open(&db_path).unwrap();
468        let row = crate::storage::memories::read_by_name(&conn, "global", "super-test")
469            .unwrap()
470            .expect("original memory must remain");
471        let metadata: serde_json::Value = serde_json::from_str(&row.metadata).unwrap();
472        assert_eq!(
473            metadata
474                .get("superseded_by_split")
475                .and_then(|v| v.as_bool()),
476            Some(true),
477            "metadata.superseded_by_split must be true after split"
478        );
479        let split_into = metadata
480            .get("split_into")
481            .and_then(|v| v.as_array())
482            .expect("metadata.split_into must be an array");
483        assert!(
484            split_into.len() >= 2,
485            "metadata.split_into must list 2+ children"
486        );
487    }
488
489    #[test]
490    fn split_body_creates_replaces_relations() {
491        let (_dir, db_path) = setup_with_memory("rel-source", 100_000);
492        let result = split_single(&db_path, "global", "rel-source", 25_000, false).unwrap();
493
494        let conn = Connection::open(&db_path).unwrap();
495        let original_entity = crate::parsers::normalize_entity_name("rel-source");
496        let original_id =
497            crate::storage::entities::find_entity_id(&conn, "global", &original_entity)
498                .unwrap()
499                .expect("original entity must exist");
500
501        let mut count = 0;
502        for child in &result.children {
503            let child_entity = crate::parsers::normalize_entity_name(&child.name);
504            let child_id = crate::storage::entities::find_entity_id(&conn, "global", &child_entity)
505                .unwrap()
506                .expect("child entity must exist");
507            let exists: bool = conn
508                .query_row(
509                    "SELECT EXISTS(SELECT 1 FROM relationships
510                     WHERE source_id = ?1 AND target_id = ?2 AND relation = 'replaces')",
511                    rusqlite::params![child_id, original_id],
512                    |r| r.get(0),
513                )
514                .unwrap();
515            assert!(
516                exists,
517                "child '{}' must `replaces` the original",
518                child.name
519            );
520            count += 1;
521        }
522        assert!(count >= 2, "expected 2+ replaces relations, got {count}");
523    }
524
525    #[test]
526    fn split_body_preserves_history() {
527        let (_dir, db_path) = setup_with_memory("hist-keep", 100_000);
528        split_single(&db_path, "global", "hist-keep", 25_000, false).unwrap();
529
530        let conn = Connection::open(&db_path).unwrap();
531        // read_by_name filters deleted_at IS NULL: original must remain visible.
532        let row = crate::storage::memories::read_by_name(&conn, "global", "hist-keep")
533            .unwrap()
534            .expect("original must remain active (not soft-deleted)");
535        assert!(row.deleted_at.is_none(), "deleted_at must stay NULL");
536    }
537
538    #[test]
539    fn dry_run_writes_nothing() {
540        let (_dir, db_path) = setup_with_memory("dry-only", 100_000);
541        let result = split_single(&db_path, "global", "dry-only", 25_000, true).unwrap();
542        assert!(result.dry_run);
543        assert!(result.children.len() >= 2);
544
545        let conn = Connection::open(&db_path).unwrap();
546        // No child should have been persisted.
547        for child in &result.children {
548            assert!(
549                crate::storage::memories::read_by_name(&conn, "global", &child.name)
550                    .unwrap()
551                    .is_none(),
552                "dry-run must not persist child '{}'",
553                child.name
554            );
555        }
556        let row = crate::storage::memories::read_by_name(&conn, "global", "dry-only")
557            .unwrap()
558            .expect("original must exist");
559        let metadata: serde_json::Value = serde_json::from_str(&row.metadata).unwrap();
560        assert!(
561            metadata.get("superseded_by_split").is_none(),
562            "dry-run must not annotate metadata"
563        );
564    }
565
566    #[test]
567    fn below_threshold_returns_empty_children() {
568        let (_dir, db_path) = setup_with_memory("small-doc", 1_000);
569        let result = split_single(&db_path, "global", "small-doc", 25_000, false).unwrap();
570        assert!(result.children.is_empty());
571    }
572
573    #[test]
574    fn validate_child_name_rejects_too_long_parent() {
575        // A parent name so long that even "{name}-part-0" exceeds MAX_MEMORY_NAME_LEN.
576        let long_parent = "a".repeat(MAX_MEMORY_NAME_LEN);
577        let child = format!("{long_parent}-part-0");
578        let err = validate_child_name(&child, &long_parent).unwrap_err();
579        assert!(matches!(err, AppError::Validation(_)));
580    }
581}