Skip to main content

semantic_memory/
documents.rs

1//! Document ingestion pipeline: chunk, embed, store, and queue sidecar updates.
2
3use crate::chunker;
4use crate::db;
5#[cfg(feature = "hnsw")]
6use crate::db::IndexOpKind;
7use crate::error::MemoryError;
8use crate::quantize::{self, Quantizer};
9use crate::types::{
10    ChunkManifestChunkMapping, ChunkManifestEntry, ChunkManifestIngestOptions,
11    ChunkManifestIngestResult, Document, SearchResult, SearchSource,
12};
13use crate::{merge_trace_ctx, MemoryStore};
14use rusqlite::{params, Connection};
15use stack_ids::ScopeKey;
16use stack_ids::TraceCtx;
17use std::collections::{BTreeMap, BTreeSet};
18
19/// A single chunk to insert: `(content, embedding_bytes, q8_bytes, token_count_estimate)`.
20pub type ChunkRow = (String, Vec<u8>, Option<Vec<u8>>, usize);
21
22pub fn insert_document_with_chunks(
23    conn: &Connection,
24    doc_id: &str,
25    title: &str,
26    namespace: &str,
27    source_path: Option<&str>,
28    metadata: Option<&serde_json::Value>,
29    chunks: &[ChunkRow],
30) -> Result<Vec<String>, MemoryError> {
31    let chunk_ids: Vec<String> = (0..chunks.len())
32        .map(|_| uuid::Uuid::new_v4().to_string())
33        .collect();
34    insert_document_with_chunks_and_ids(
35        conn,
36        doc_id,
37        title,
38        namespace,
39        source_path,
40        metadata,
41        chunks,
42        &chunk_ids,
43    )?;
44    Ok(chunk_ids)
45}
46
47#[allow(clippy::too_many_arguments)]
48pub fn insert_document_with_chunks_and_ids(
49    conn: &Connection,
50    doc_id: &str,
51    title: &str,
52    namespace: &str,
53    source_path: Option<&str>,
54    metadata: Option<&serde_json::Value>,
55    chunks: &[ChunkRow],
56    chunk_ids: &[String],
57) -> Result<(), MemoryError> {
58    if chunks.len() != chunk_ids.len() {
59        return Err(MemoryError::Other(
60            "chunks and chunk_ids must have the same length".to_string(),
61        ));
62    }
63
64    let metadata_str = metadata.map(|value| value.to_string());
65    db::with_transaction(conn, |tx| {
66        tx.execute(
67            "INSERT INTO documents (id, title, source_path, namespace, metadata)
68             VALUES (?1, ?2, ?3, ?4, ?5)",
69            params![doc_id, title, source_path, namespace, metadata_str],
70        )?;
71
72        for (chunk_index, ((content, embedding_bytes, q8_bytes, token_count), chunk_id)) in
73            chunks.iter().zip(chunk_ids.iter()).enumerate()
74        {
75            tx.execute(
76                "INSERT INTO chunks (id, document_id, chunk_index, content, token_count, embedding, embedding_q8)
77                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
78                params![
79                    chunk_id,
80                    doc_id,
81                    chunk_index as i64,
82                    content,
83                    *token_count as i64,
84                    embedding_bytes,
85                    q8_bytes.as_deref()
86                ],
87            )?;
88
89            tx.execute(
90                "INSERT INTO chunks_rowid_map (chunk_id) VALUES (?1)",
91                params![chunk_id],
92            )?;
93            let fts_rowid = tx.last_insert_rowid();
94            tx.execute(
95                "INSERT INTO chunks_fts (rowid, content) VALUES (?1, ?2)",
96                params![fts_rowid, content],
97            )?;
98
99            #[cfg(feature = "hnsw")]
100            db::queue_pending_index_op(
101                tx,
102                &format!("chunk:{}", chunk_id),
103                "chunk",
104                IndexOpKind::Upsert,
105            )?;
106            db::invalidate_derived_vector_artifact(tx, &format!("chunk:{chunk_id}"))?;
107        }
108
109        Ok(())
110    })
111}
112
113pub fn delete_document_with_chunks(
114    conn: &Connection,
115    document_id: &str,
116) -> Result<Vec<String>, MemoryError> {
117    db::with_transaction(conn, |tx| {
118        let episode_rows: Vec<(String, String, i64)> = {
119            let mut stmt = tx.prepare(
120                "SELECT e.episode_id, e.search_text, erm.rowid
121                 FROM episodes e
122                 JOIN episodes_rowid_map erm ON erm.episode_id = e.episode_id
123                 WHERE e.document_id = ?1",
124            )?;
125            let rows = stmt.query_map(params![document_id], |row| {
126                Ok((row.get(0)?, row.get(1)?, row.get(2)?))
127            })?;
128            rows.collect::<Result<Vec<_>, _>>()?
129        };
130
131        for (episode_id, search_text, fts_rowid) in &episode_rows {
132            tx.execute(
133                "INSERT INTO episodes_fts (episodes_fts, rowid, content) VALUES ('delete', ?1, ?2)",
134                params![fts_rowid, search_text],
135            )?;
136            tx.execute(
137                "DELETE FROM episodes_rowid_map WHERE episode_id = ?1",
138                params![episode_id],
139            )?;
140            tx.execute(
141                "DELETE FROM episode_causes WHERE episode_id = ?1",
142                params![episode_id],
143            )?;
144            #[cfg(feature = "hnsw")]
145            db::queue_pending_index_op(
146                tx,
147                &crate::episodes::episode_item_key(episode_id),
148                "episode",
149                IndexOpKind::Delete,
150            )?;
151            db::invalidate_derived_vector_artifact(
152                tx,
153                &crate::episodes::episode_item_key(episode_id),
154            )?;
155        }
156        tx.execute(
157            "DELETE FROM episodes WHERE document_id = ?1",
158            params![document_id],
159        )?;
160
161        let mut stmt = tx.prepare(
162            "SELECT c.id, c.content, cm.rowid
163             FROM chunks c
164             JOIN chunks_rowid_map cm ON cm.chunk_id = c.id
165             WHERE c.document_id = ?1",
166        )?;
167        let chunk_rows: Vec<(String, String, i64)> = stmt
168            .query_map(params![document_id], |row| {
169                Ok((row.get(0)?, row.get(1)?, row.get(2)?))
170            })?
171            .collect::<Result<Vec<_>, _>>()?;
172
173        let chunk_ids: Vec<String> = chunk_rows.iter().map(|(id, _, _)| id.clone()).collect();
174
175        for (chunk_id, content, fts_rowid) in &chunk_rows {
176            tx.execute(
177                "INSERT INTO chunks_fts (chunks_fts, rowid, content) VALUES ('delete', ?1, ?2)",
178                params![fts_rowid, content],
179            )?;
180            tx.execute(
181                "DELETE FROM chunks_rowid_map WHERE chunk_id = ?1",
182                params![chunk_id],
183            )?;
184            #[cfg(feature = "hnsw")]
185            db::queue_pending_index_op(
186                tx,
187                &format!("chunk:{}", chunk_id),
188                "chunk",
189                IndexOpKind::Delete,
190            )?;
191            db::invalidate_derived_vector_artifact(tx, &format!("chunk:{chunk_id}"))?;
192        }
193
194        tx.execute(
195            "DELETE FROM chunks WHERE document_id = ?1",
196            params![document_id],
197        )?;
198        let affected = tx.execute("DELETE FROM documents WHERE id = ?1", params![document_id])?;
199        if affected == 0 {
200            return Err(MemoryError::DocumentNotFound(document_id.to_string()));
201        }
202
203        Ok(chunk_ids)
204    })
205}
206
207pub fn count_chunks_for_document(
208    conn: &Connection,
209    document_id: &str,
210) -> Result<usize, MemoryError> {
211    let count: i64 = conn.query_row(
212        "SELECT COUNT(*) FROM chunks WHERE document_id = ?1",
213        params![document_id],
214        |row| row.get(0),
215    )?;
216    Ok(count as usize)
217}
218
219pub fn list_documents(
220    conn: &Connection,
221    namespace: &str,
222    limit: usize,
223    offset: usize,
224) -> Result<Vec<Document>, MemoryError> {
225    let mut stmt = conn.prepare(
226        "SELECT d.id, d.title, d.source_path, d.namespace, d.created_at, d.metadata,
227                (SELECT COUNT(*) FROM chunks c WHERE c.document_id = d.id) AS chunk_count
228         FROM documents d
229         WHERE d.namespace = ?1
230         ORDER BY d.created_at DESC
231         LIMIT ?2 OFFSET ?3",
232    )?;
233
234    let rows = stmt
235        .query_map(params![namespace, limit as i64, offset as i64], |row| {
236            Ok((
237                row.get::<_, String>(0)?,
238                row.get::<_, String>(1)?,
239                row.get::<_, Option<String>>(2)?,
240                row.get::<_, String>(3)?,
241                row.get::<_, String>(4)?,
242                row.get::<_, Option<String>>(5)?,
243                row.get::<_, i64>(6)? as u32,
244            ))
245        })?
246        .collect::<Result<Vec<_>, _>>()?;
247
248    rows.into_iter()
249        .map(
250            |(id, title, source_path, namespace, created_at, metadata_raw, chunk_count)| {
251                Ok(Document {
252                    metadata: db::parse_optional_json(
253                        "documents",
254                        &id,
255                        "metadata",
256                        metadata_raw.as_deref(),
257                    )?,
258                    id,
259                    title,
260                    source_path,
261                    namespace,
262                    created_at,
263                    chunk_count,
264                })
265            },
266        )
267        .collect()
268}
269
270fn document_scope_keys_for_ids(
271    conn: &Connection,
272    document_ids: &[String],
273) -> Result<BTreeMap<String, ScopeKey>, MemoryError> {
274    if document_ids.is_empty() {
275        return Ok(BTreeMap::new());
276    }
277
278    let placeholders = (0..document_ids.len())
279        .map(|_| "?")
280        .collect::<Vec<_>>()
281        .join(", ");
282    let sql = format!("SELECT id, namespace, metadata FROM documents WHERE id IN ({placeholders})");
283    let params: Vec<&str> = document_ids.iter().map(|id| id.as_str()).collect();
284    let mut stmt = conn.prepare(&sql)?;
285    let rows = stmt
286        .query_map(rusqlite::params_from_iter(&params), |row| {
287            Ok((
288                row.get::<_, String>(0)?,
289                row.get::<_, String>(1)?,
290                row.get::<_, Option<String>>(2)?,
291            ))
292        })?
293        .collect::<Result<Vec<_>, _>>()?;
294
295    let mut by_id = BTreeMap::new();
296    for (id, namespace, metadata_raw) in rows {
297        let metadata =
298            db::parse_optional_json("documents", &id, "metadata", metadata_raw.as_deref())?;
299        let scope_key = ScopeKey {
300            namespace,
301            domain: metadata
302                .as_ref()
303                .and_then(|value| value.get("scope_domain"))
304                .and_then(|value| value.as_str())
305                .map(str::to_string),
306            workspace_id: metadata
307                .as_ref()
308                .and_then(|value| value.get("scope_workspace_id"))
309                .and_then(|value| value.as_str())
310                .map(str::to_string),
311            repo_id: metadata
312                .as_ref()
313                .and_then(|value| value.get("scope_repo_id"))
314                .and_then(|value| value.as_str())
315                .map(str::to_string),
316        };
317        by_id.insert(id, scope_key);
318    }
319
320    Ok(by_id)
321}
322
323impl MemoryStore {
324    /// Ingest a document: chunk, embed all chunks, store everything.
325    pub async fn ingest_document(
326        &self,
327        title: &str,
328        content: &str,
329        namespace: &str,
330        source_path: Option<&str>,
331        metadata: Option<serde_json::Value>,
332    ) -> Result<String, MemoryError> {
333        self.ingest_document_with_trace(title, content, namespace, source_path, metadata, None)
334            .await
335    }
336
337    /// Ingest a document with optional trace metadata.
338    pub async fn ingest_document_with_trace(
339        &self,
340        title: &str,
341        content: &str,
342        namespace: &str,
343        source_path: Option<&str>,
344        metadata: Option<serde_json::Value>,
345        trace_ctx: Option<&TraceCtx>,
346    ) -> Result<String, MemoryError> {
347        self.validate_content("document.content", content)?;
348
349        // Dedup: check if a document with the same title already exists
350        // in the same namespace. Return the existing document ID instead
351        // of creating a duplicate.
352        let title_check = title.to_string();
353        let ns_check = namespace.to_string();
354        let existing_id = self
355            .with_read_conn(move |conn| {
356                let result: Option<String> = conn
357                    .query_row(
358                        "SELECT id FROM documents WHERE title = ?1 AND namespace = ?2 LIMIT 1",
359                        rusqlite::params![&title_check, &ns_check],
360                        |row| row.get::<_, String>(0),
361                    )
362                    .ok();
363                Ok(result)
364            })
365            .await?;
366
367        if let Some(id) = existing_id {
368            return Ok(id);
369        }
370
371        let text_chunks = chunker::chunk_text(
372            content,
373            &self.inner.config.chunking,
374            self.inner.token_counter.as_ref(),
375        );
376
377        let max_chunks = self.inner.config.limits.max_chunks_per_document;
378        if text_chunks.len() > max_chunks {
379            return Err(MemoryError::ContentTooLarge {
380                size: text_chunks.len(),
381                limit: max_chunks,
382            });
383        }
384
385        let chunk_texts: Vec<String> = text_chunks.iter().map(|c| c.content.clone()).collect();
386        let embeddings = self.embed_batch_internal(chunk_texts).await?;
387        for embedding in &embeddings {
388            self.validate_embedding_dimensions(embedding)?;
389        }
390
391        let quantizer = Quantizer::new(self.inner.config.embedding.dimensions);
392        let chunks: Vec<ChunkRow> = text_chunks
393            .iter()
394            .zip(embeddings.iter())
395            .map(|(tc, emb)| {
396                // INTENTIONAL: q8 quantization is an optional search optimization; missing q8 is non-fatal
397                let q8 = quantizer
398                    .quantize(emb)
399                    .map(|qv| quantize::pack_quantized(&qv))
400                    .ok();
401                (
402                    tc.content.clone(),
403                    db::embedding_to_bytes(emb),
404                    q8,
405                    tc.token_count_estimate,
406                )
407            })
408            .collect();
409
410        let doc_id = uuid::Uuid::new_v4().to_string();
411
412        let did = doc_id.clone();
413        let t = title.to_string();
414        let ns = namespace.to_string();
415        let sp = source_path.map(|s| s.to_string());
416        let meta = merge_trace_ctx(metadata, trace_ctx);
417
418        self.with_write_conn(move |conn| {
419            insert_document_with_chunks(conn, &did, &t, &ns, sp.as_deref(), meta.as_ref(), &chunks)
420        })
421        .await?;
422
423        #[cfg(feature = "hnsw")]
424        self.sync_pending_hnsw_ops_best_effort("ingest_document")
425            .await;
426
427        Ok(doc_id)
428    }
429
430    /// Ingest an externally chunked document manifest and return exact chunk mappings.
431    ///
432    /// This API preserves semantic-memory as the owner of document/chunk storage and embeddings:
433    /// callers provide chunk boundaries and external IDs, while semantic-memory generates and
434    /// stores its own document/chunk IDs atomically.
435    pub async fn ingest_chunk_manifest(
436        &self,
437        options: ChunkManifestIngestOptions,
438        entries: Vec<ChunkManifestEntry>,
439    ) -> Result<ChunkManifestIngestResult, MemoryError> {
440        self.ingest_chunk_manifest_with_trace(options, entries, None)
441            .await
442    }
443
444    /// Ingest an externally chunked document manifest with optional trace metadata.
445    pub async fn ingest_chunk_manifest_with_trace(
446        &self,
447        options: ChunkManifestIngestOptions,
448        entries: Vec<ChunkManifestEntry>,
449        trace_ctx: Option<&TraceCtx>,
450    ) -> Result<ChunkManifestIngestResult, MemoryError> {
451        if entries.is_empty() {
452            return Err(MemoryError::InvalidConfig {
453                field: "chunk_manifest.entries",
454                reason: "at least one chunk is required".to_string(),
455            });
456        }
457
458        let max_chunks = self.inner.config.limits.max_chunks_per_document;
459        if entries.len() > max_chunks {
460            return Err(MemoryError::ContentTooLarge {
461                size: entries.len(),
462                limit: max_chunks,
463            });
464        }
465
466        let mut seen_external_ids = BTreeSet::new();
467        for (index, entry) in entries.iter().enumerate() {
468            let external_chunk_id = entry.external_chunk_id.trim();
469            if external_chunk_id.is_empty() {
470                return Err(MemoryError::InvalidConfig {
471                    field: "chunk_manifest.external_chunk_id",
472                    reason: format!("chunk {index} external_chunk_id must not be empty"),
473                });
474            }
475            if !seen_external_ids.insert(external_chunk_id.to_string()) {
476                return Err(MemoryError::InvalidConfig {
477                    field: "chunk_manifest.external_chunk_id",
478                    reason: format!("duplicate external_chunk_id '{external_chunk_id}'"),
479                });
480            }
481            if entry.content.trim().is_empty() {
482                return Err(MemoryError::InvalidConfig {
483                    field: "chunk_manifest.content",
484                    reason: format!(
485                        "content must not be empty (chunk index {index}, id='{external_chunk_id}')"
486                    ),
487                });
488            }
489            self.validate_content("chunk_manifest.content", &entry.content)?;
490            if entry
491                .content_digest
492                .as_deref()
493                .is_some_and(|digest| digest.trim().is_empty())
494            {
495                return Err(MemoryError::InvalidConfig {
496                    field: "chunk_manifest.content_digest",
497                    reason: format!("chunk {index} content_digest must not be empty when supplied"),
498                });
499            }
500        }
501
502        let chunk_texts: Vec<String> = entries.iter().map(|entry| entry.content.clone()).collect();
503        let embeddings = self.embed_batch_internal(chunk_texts).await?;
504        for embedding in &embeddings {
505            self.validate_embedding_dimensions(embedding)?;
506        }
507
508        let quantizer = Quantizer::new(self.inner.config.embedding.dimensions);
509        let chunks: Vec<ChunkRow> = entries
510            .iter()
511            .zip(embeddings.iter())
512            .map(|(entry, emb)| {
513                let q8 = quantizer
514                    .quantize(emb)
515                    .map(|qv| quantize::pack_quantized(&qv))
516                    .ok();
517                (
518                    entry.content.clone(),
519                    db::embedding_to_bytes(emb),
520                    q8,
521                    entry
522                        .token_count_estimate
523                        .unwrap_or_else(|| entry.content.len().div_ceil(4).max(1)),
524                )
525            })
526            .collect();
527
528        let doc_id = uuid::Uuid::new_v4().to_string();
529        let chunk_ids: Vec<String> = (0..entries.len())
530            .map(|_| uuid::Uuid::new_v4().to_string())
531            .collect();
532        let receipt_id = format!("chunk-manifest:{}", uuid::Uuid::new_v4());
533
534        let mappings: Vec<ChunkManifestChunkMapping> = entries
535            .iter()
536            .zip(chunk_ids.iter())
537            .enumerate()
538            .map(
539                |(chunk_index, (entry, sm_chunk_id))| ChunkManifestChunkMapping {
540                    external_chunk_id: entry.external_chunk_id.clone(),
541                    sm_document_id: doc_id.clone(),
542                    sm_chunk_id: sm_chunk_id.clone(),
543                    chunk_index,
544                    content_digest: entry.content_digest.clone(),
545                    metadata: entry.metadata.clone(),
546                },
547            )
548            .collect();
549
550        let did = doc_id.clone();
551        let title = options.title;
552        let namespace = options.namespace;
553        let source_path = options.source_path;
554        let metadata = merge_trace_ctx(options.metadata, trace_ctx);
555        let namespace_for_result = namespace.clone();
556
557        self.with_write_conn(move |conn| {
558            insert_document_with_chunks_and_ids(
559                conn,
560                &did,
561                &title,
562                &namespace,
563                source_path.as_deref(),
564                metadata.as_ref(),
565                &chunks,
566                &chunk_ids,
567            )
568        })
569        .await?;
570
571        #[cfg(feature = "hnsw")]
572        self.sync_pending_hnsw_ops_best_effort("ingest_chunk_manifest")
573            .await;
574
575        Ok(ChunkManifestIngestResult {
576            sm_document_id: doc_id,
577            namespace: namespace_for_result,
578            receipt_id,
579            chunks: mappings,
580        })
581    }
582
583    /// Delete a document and all its chunks.
584    pub async fn delete_document(&self, document_id: &str) -> Result<(), MemoryError> {
585        let did = document_id.to_string();
586        self.with_write_conn(move |conn| delete_document_with_chunks(conn, &did))
587            .await?;
588
589        #[cfg(feature = "hnsw")]
590        self.sync_pending_hnsw_ops_best_effort("delete_document")
591            .await;
592
593        Ok(())
594    }
595
596    /// List documents in a namespace.
597    pub async fn list_documents(
598        &self,
599        namespace: &str,
600        limit: usize,
601        offset: usize,
602    ) -> Result<Vec<Document>, MemoryError> {
603        let ns = namespace.to_string();
604        self.with_read_conn(move |conn| list_documents(conn, &ns, limit, offset))
605            .await
606    }
607
608    /// Count the number of chunks for a document.
609    pub async fn count_chunks_for_document(&self, document_id: &str) -> Result<usize, MemoryError> {
610        let did = document_id.to_string();
611        self.with_read_conn(move |conn| count_chunks_for_document(conn, &did))
612            .await
613    }
614
615    /// Filter search results to those whose source scope exactly matches the requested scope.
616    ///
617    /// Only source families that carry or can be joined to full scope metadata are retained:
618    /// chunks, episodes, and imported projection rows. Facts and messages are excluded because
619    /// they do not carry domain/workspace/repo provenance.
620    pub async fn filter_search_results_by_scope(
621        &self,
622        results: Vec<SearchResult>,
623        scope: &ScopeKey,
624    ) -> Result<Vec<SearchResult>, MemoryError> {
625        let mut document_ids = BTreeSet::new();
626        for result in &results {
627            match &result.source {
628                SearchSource::Chunk { document_id, .. }
629                | SearchSource::Episode { document_id, .. } => {
630                    document_ids.insert(document_id.clone());
631                }
632                _ => {}
633            }
634        }
635
636        let document_ids = document_ids.into_iter().collect::<Vec<_>>();
637        let scope_by_document = self
638            .with_read_conn(move |conn| document_scope_keys_for_ids(conn, &document_ids))
639            .await?;
640        let requested = scope.clone();
641
642        Ok(results
643            .into_iter()
644            .filter(|result| match &result.source {
645                SearchSource::Chunk { document_id, .. }
646                | SearchSource::Episode { document_id, .. } => scope_by_document
647                    .get(document_id)
648                    .map(|scope_key| scope_key == &requested)
649                    .unwrap_or(false),
650                SearchSource::Projection { scope_key, .. } => scope_key == &requested,
651                SearchSource::Fact { .. } | SearchSource::Message { .. } => false,
652            })
653            .collect())
654    }
655}