Skip to main content

zeph_memory/document/
pipeline.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use 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    /// Ingest a document: split -> embed -> store in Qdrant. Returns chunk count.
34    ///
35    /// # Errors
36    ///
37    /// Returns an error if embedding, embedding timeout, or Qdrant storage fails.
38    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    /// # Errors
86    ///
87    /// Returns an error if loading, embedding, or storage fails.
88    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    /// Embed fn that sleeps longer than `IngestionPipeline::ingest`'s 15 s
133    /// `CHUNK_EMBED_TIMEOUT_SECS`, used to exercise the timeout path without a real wall-clock
134    /// wait (paired with `#[tokio::test(start_paused = true)]`).
135    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        // Empty document should short-circuit before calling Qdrant.
149        // We use an invalid Qdrant URL; the early-return path won't reach it.
150        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        // Embedding failure should return DocumentError without reaching Qdrant.
162        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    /// Regression for #5387: an embed provider that never responds must not hang `ingest`
172    /// indefinitely. The per-chunk embed call is bounded by a 15 s timeout that surfaces as
173    /// `DocumentError::Embedding(LlmError::Timeout)`. Uses `start_paused` + a sleeping `embed_fn`
174    /// so the timeout fires against virtual time instead of a real 15 s wait.
175    #[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}