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.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 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 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 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 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 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 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 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}