1use 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
19pub 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(¶ms), |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 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 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 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 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 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 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 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 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 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 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}