zeph_memory/document/
pipeline.rs1use qdrant_client::qdrant::PointStruct;
5use serde_json::json;
6use uuid::Uuid;
7
8use super::{Document, DocumentError, DocumentLoader, TextSplitter};
9use crate::QdrantOps;
10
11pub struct IngestionPipeline {
12 splitter: TextSplitter,
13 qdrant: QdrantOps,
14 collection: String,
15 embed_fn: Box<dyn Fn(&str) -> zeph_llm::provider::EmbedFuture + Send + Sync>,
16}
17
18impl IngestionPipeline {
19 pub fn new(
20 splitter: TextSplitter,
21 qdrant: QdrantOps,
22 collection: impl Into<String>,
23 embed_fn: Box<dyn Fn(&str) -> zeph_llm::provider::EmbedFuture + Send + Sync>,
24 ) -> Self {
25 Self {
26 splitter,
27 qdrant,
28 collection: collection.into(),
29 embed_fn,
30 }
31 }
32
33 pub async fn ingest(&self, document: Document) -> Result<usize, DocumentError> {
39 const CHUNK_EMBED_TIMEOUT_SECS: u64 = 15;
40
41 let chunks = self.splitter.split(&document);
42 if chunks.is_empty() {
43 return Ok(0);
44 }
45
46 let mut points = Vec::with_capacity(chunks.len());
47 for chunk in &chunks {
48 let vector = tokio::time::timeout(
49 std::time::Duration::from_secs(CHUNK_EMBED_TIMEOUT_SECS),
50 (self.embed_fn)(&chunk.content),
51 )
52 .await
53 .map_err(|_| {
54 tracing::warn!(
55 timeout_secs = CHUNK_EMBED_TIMEOUT_SECS,
56 source = %chunk.metadata.source,
57 "embedding provider timed out during chunk ingest"
58 );
59 zeph_llm::LlmError::Timeout
60 })??;
61 let payload = QdrantOps::json_to_payload(json!({
62 "source": chunk.metadata.source,
63 "content_type": chunk.metadata.content_type,
64 "chunk_index": chunk.chunk_index,
65 "content": chunk.content,
66 }))
67 .map_err(|e| DocumentError::Storage(crate::error::MemoryError::Json(e)))?;
68
69 points.push(PointStruct::new(
70 Uuid::new_v4().to_string(),
71 vector,
72 payload,
73 ));
74 }
75
76 let count = points.len();
77 self.qdrant
78 .upsert(&self.collection, points)
79 .await
80 .map_err(|e| DocumentError::Storage(crate::error::MemoryError::Qdrant(e)))?;
81
82 Ok(count)
83 }
84
85 pub async fn load_and_ingest(
89 &self,
90 loader: &(dyn DocumentLoader + '_),
91 path: &std::path::Path,
92 ) -> Result<usize, DocumentError> {
93 let documents = loader.load(path).await?;
94 let mut total = 0;
95 for doc in documents {
96 total += self.ingest(doc).await?;
97 }
98 Ok(total)
99 }
100}
101
102#[cfg(test)]
103mod tests {
104 use super::*;
105 use crate::document::splitter::SplitterConfig;
106 use crate::document::types::DocumentMetadata;
107 use std::collections::HashMap;
108
109 fn make_document(content: &str) -> Document {
110 Document {
111 content: content.to_string(),
112 metadata: DocumentMetadata {
113 source: "test".to_string(),
114 content_type: "text/plain".to_string(),
115 extra: HashMap::new(),
116 },
117 }
118 }
119
120 fn noop_embed() -> Box<dyn Fn(&str) -> zeph_llm::provider::EmbedFuture + Send + Sync> {
121 Box::new(|_text: &str| Box::pin(async move { Ok(vec![0.0f32; 4]) }))
122 }
123
124 fn error_embed() -> Box<dyn Fn(&str) -> zeph_llm::provider::EmbedFuture + Send + Sync> {
125 Box::new(|_text: &str| {
126 Box::pin(
127 async move { Err(zeph_llm::error::LlmError::Other("mock embed error".into())) },
128 )
129 })
130 }
131
132 fn stalled_embed(
136 delay_secs: u64,
137 ) -> Box<dyn Fn(&str) -> zeph_llm::provider::EmbedFuture + Send + Sync> {
138 Box::new(move |_text: &str| {
139 Box::pin(async move {
140 tokio::time::sleep(std::time::Duration::from_secs(delay_secs)).await;
141 Ok(vec![0.0f32; 4])
142 })
143 })
144 }
145
146 #[tokio::test]
147 async fn ingest_empty_document_returns_zero() {
148 let qdrant = crate::QdrantOps::new("http://127.0.0.1:1", None).unwrap();
151 let splitter = TextSplitter::new(SplitterConfig::default());
152 let pipeline = IngestionPipeline::new(splitter, qdrant, "col", noop_embed());
153
154 let doc = make_document("");
155 let count = pipeline.ingest(doc).await.unwrap();
156 assert_eq!(count, 0);
157 }
158
159 #[tokio::test]
160 async fn ingest_document_embedding_error_propagates() {
161 let qdrant = crate::QdrantOps::new("http://127.0.0.1:1", None).unwrap();
163 let splitter = TextSplitter::new(SplitterConfig::default());
164 let pipeline = IngestionPipeline::new(splitter, qdrant, "col", error_embed());
165
166 let doc = make_document("hello world, this is test content for embedding");
167 let result = pipeline.ingest(doc).await;
168 assert!(result.is_err(), "expected error from embedding failure");
169 }
170
171 #[tokio::test(start_paused = true)]
176 async fn ingest_embed_timeout_returns_error_without_hanging() {
177 let qdrant = crate::QdrantOps::new("http://127.0.0.1:1", None).unwrap();
178 let splitter = TextSplitter::new(SplitterConfig::default());
179 let pipeline = IngestionPipeline::new(splitter, qdrant, "col", stalled_embed(16));
180
181 let doc = make_document("hello world, this is test content for embedding");
182 let result = pipeline.ingest(doc).await;
183
184 match result {
185 Err(DocumentError::Embedding(zeph_llm::LlmError::Timeout)) => {}
186 other => panic!("expected DocumentError::Embedding(LlmError::Timeout), got {other:?}"),
187 }
188 }
189}