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.embed_batch_with_sparse_internal(chunk_texts).await?;
404
405        let quantizer = Quantizer::new(self.inner.config.embedding.dimensions);
406        let chunks: Vec<ChunkRow> = text_chunks
407            .iter()
408            .zip(embeddings.iter())
409            .map(|(tc, (emb, sparse, sparse_representation))| {
410                // INTENTIONAL: q8 quantization is an optional search optimization; missing q8 is non-fatal
411                let q8 = quantizer
412                    .quantize(emb)
413                    .map(|qv| quantize::pack_quantized(&qv))
414                    .ok();
415                (
416                    tc.content.clone(),
417                    db::embedding_to_bytes(emb),
418                    q8,
419                    tc.token_count_estimate,
420                    sparse.clone(),
421                    sparse_representation.clone(),
422                )
423            })
424            .collect();
425
426        let doc_id = uuid::Uuid::new_v4().to_string();
427
428        let did = doc_id.clone();
429        let t = title.to_string();
430        let ns = namespace.to_string();
431        let sp = source_path.map(|s| s.to_string());
432        let meta = merge_trace_ctx(metadata, trace_ctx);
433
434        self.with_write_conn(move |conn| {
435            insert_document_with_chunks(conn, &did, &t, &ns, sp.as_deref(), meta.as_ref(), &chunks)
436        })
437        .await?;
438
439        #[cfg(feature = "hnsw")]
440        self.sync_pending_hnsw_ops_best_effort("ingest_document")
441            .await;
442
443        Ok(doc_id)
444    }
445
446    /// Ingest an externally chunked document manifest and return exact chunk mappings.
447    ///
448    /// This API preserves semantic-memory as the owner of document/chunk storage and embeddings:
449    /// callers provide chunk boundaries and external IDs, while semantic-memory generates and
450    /// stores its own document/chunk IDs atomically.
451    pub async fn ingest_chunk_manifest(
452        &self,
453        options: ChunkManifestIngestOptions,
454        entries: Vec<ChunkManifestEntry>,
455    ) -> Result<ChunkManifestIngestResult, MemoryError> {
456        self.ingest_chunk_manifest_with_trace(options, entries, None)
457            .await
458    }
459
460    /// Ingest an externally chunked document manifest with optional trace metadata.
461    pub async fn ingest_chunk_manifest_with_trace(
462        &self,
463        options: ChunkManifestIngestOptions,
464        entries: Vec<ChunkManifestEntry>,
465        trace_ctx: Option<&TraceCtx>,
466    ) -> Result<ChunkManifestIngestResult, MemoryError> {
467        if entries.is_empty() {
468            return Err(MemoryError::InvalidConfig {
469                field: "chunk_manifest.entries",
470                reason: "at least one chunk is required".to_string(),
471            });
472        }
473
474        let max_chunks = self.inner.config.limits.max_chunks_per_document;
475        if entries.len() > max_chunks {
476            return Err(MemoryError::ContentTooLarge {
477                size: entries.len(),
478                limit: max_chunks,
479            });
480        }
481
482        let mut seen_external_ids = BTreeSet::new();
483        for (index, entry) in entries.iter().enumerate() {
484            let external_chunk_id = entry.external_chunk_id.trim();
485            if external_chunk_id.is_empty() {
486                return Err(MemoryError::InvalidConfig {
487                    field: "chunk_manifest.external_chunk_id",
488                    reason: format!("chunk {index} external_chunk_id must not be empty"),
489                });
490            }
491            if !seen_external_ids.insert(external_chunk_id.to_string()) {
492                return Err(MemoryError::InvalidConfig {
493                    field: "chunk_manifest.external_chunk_id",
494                    reason: format!("duplicate external_chunk_id '{external_chunk_id}'"),
495                });
496            }
497            if entry.content.trim().is_empty() {
498                return Err(MemoryError::InvalidConfig {
499                    field: "chunk_manifest.content",
500                    reason: format!(
501                        "content must not be empty (chunk index {index}, id='{external_chunk_id}')"
502                    ),
503                });
504            }
505            self.validate_content("chunk_manifest.content", &entry.content)?;
506            if entry
507                .content_digest
508                .as_deref()
509                .is_some_and(|digest| digest.trim().is_empty())
510            {
511                return Err(MemoryError::InvalidConfig {
512                    field: "chunk_manifest.content_digest",
513                    reason: format!("chunk {index} content_digest must not be empty when supplied"),
514                });
515            }
516        }
517
518        let chunk_texts: Vec<String> = entries.iter().map(|entry| entry.content.clone()).collect();
519        let embeddings = self.embed_batch_with_sparse_internal(chunk_texts).await?;
520
521        let quantizer = Quantizer::new(self.inner.config.embedding.dimensions);
522        let chunks: Vec<ChunkRow> = entries
523            .iter()
524            .zip(embeddings.iter())
525            .map(|(entry, (emb, sparse, sparse_representation))| {
526                let q8 = quantizer
527                    .quantize(emb)
528                    .map(|qv| quantize::pack_quantized(&qv))
529                    .ok();
530                (
531                    entry.content.clone(),
532                    db::embedding_to_bytes(emb),
533                    q8,
534                    entry
535                        .token_count_estimate
536                        .unwrap_or_else(|| entry.content.len().div_ceil(4).max(1)),
537                    sparse.clone(),
538                    sparse_representation.clone(),
539                )
540            })
541            .collect();
542
543        let doc_id = uuid::Uuid::new_v4().to_string();
544        let chunk_ids: Vec<String> = (0..entries.len())
545            .map(|_| uuid::Uuid::new_v4().to_string())
546            .collect();
547        let receipt_id = format!("chunk-manifest:{}", uuid::Uuid::new_v4());
548
549        let mappings: Vec<ChunkManifestChunkMapping> = entries
550            .iter()
551            .zip(chunk_ids.iter())
552            .enumerate()
553            .map(
554                |(chunk_index, (entry, sm_chunk_id))| ChunkManifestChunkMapping {
555                    external_chunk_id: entry.external_chunk_id.clone(),
556                    sm_document_id: doc_id.clone(),
557                    sm_chunk_id: sm_chunk_id.clone(),
558                    chunk_index,
559                    content_digest: entry.content_digest.clone(),
560                    metadata: entry.metadata.clone(),
561                },
562            )
563            .collect();
564
565        let did = doc_id.clone();
566        let title = options.title;
567        let namespace = options.namespace;
568        let source_path = options.source_path;
569        let metadata = merge_trace_ctx(options.metadata, trace_ctx);
570        let namespace_for_result = namespace.clone();
571
572        self.with_write_conn(move |conn| {
573            insert_document_with_chunks_and_ids(
574                conn,
575                &did,
576                &title,
577                &namespace,
578                source_path.as_deref(),
579                metadata.as_ref(),
580                &chunks,
581                &chunk_ids,
582            )
583        })
584        .await?;
585
586        #[cfg(feature = "hnsw")]
587        self.sync_pending_hnsw_ops_best_effort("ingest_chunk_manifest")
588            .await;
589
590        Ok(ChunkManifestIngestResult {
591            sm_document_id: doc_id,
592            namespace: namespace_for_result,
593            receipt_id,
594            chunks: mappings,
595        })
596    }
597
598    /// Delete a document and all its chunks.
599    pub async fn delete_document(&self, document_id: &str) -> Result<(), MemoryError> {
600        let did = document_id.to_string();
601        self.with_write_conn(move |conn| delete_document_with_chunks(conn, &did))
602            .await?;
603
604        #[cfg(feature = "hnsw")]
605        self.sync_pending_hnsw_ops_best_effort("delete_document")
606            .await;
607
608        Ok(())
609    }
610
611    /// List documents in a namespace.
612    pub async fn list_documents(
613        &self,
614        namespace: &str,
615        limit: usize,
616        offset: usize,
617    ) -> Result<Vec<Document>, MemoryError> {
618        let ns = namespace.to_string();
619        self.with_read_conn(move |conn| list_documents(conn, &ns, limit, offset))
620            .await
621    }
622
623    /// Count the number of chunks for a document.
624    pub async fn count_chunks_for_document(&self, document_id: &str) -> Result<usize, MemoryError> {
625        let did = document_id.to_string();
626        self.with_read_conn(move |conn| count_chunks_for_document(conn, &did))
627            .await
628    }
629
630    /// Filter search results to those whose source scope exactly matches the requested scope.
631    ///
632    /// Only source families that carry or can be joined to full scope metadata are retained:
633    /// chunks, episodes, and imported projection rows. Facts and messages are excluded because
634    /// they do not carry domain/workspace/repo provenance.
635    pub async fn filter_search_results_by_scope(
636        &self,
637        results: Vec<SearchResult>,
638        scope: &ScopeKey,
639    ) -> Result<Vec<SearchResult>, MemoryError> {
640        let mut document_ids = BTreeSet::new();
641        for result in &results {
642            match &result.source {
643                SearchSource::Chunk { document_id, .. }
644                | SearchSource::Episode { document_id, .. } => {
645                    document_ids.insert(document_id.clone());
646                }
647                _ => {}
648            }
649        }
650
651        let document_ids = document_ids.into_iter().collect::<Vec<_>>();
652        let scope_by_document = self
653            .with_read_conn(move |conn| document_scope_keys_for_ids(conn, &document_ids))
654            .await?;
655        let requested = scope.clone();
656
657        Ok(results
658            .into_iter()
659            .filter(|result| match &result.source {
660                SearchSource::Chunk { document_id, .. }
661                | SearchSource::Episode { document_id, .. } => scope_by_document
662                    .get(document_id)
663                    .map(|scope_key| scope_key == &requested)
664                    .unwrap_or(false),
665                SearchSource::Projection { scope_key, .. } => scope_key == &requested,
666                SearchSource::Fact { .. } | SearchSource::Message { .. } => false,
667            })
668            .collect())
669    }
670}