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