Skip to main content

lc_rag/
hyde.rs

1// src/retrieval/hyde.rs
2//! HyDE (Hypothetical Document Embedding) Retriever implementation
3//!
4//! Uses an LLM to generate a hypothetical document, then retrieves with that hypothetical
5//! document, improving retrieval recall and precision.
6
7use lc_core::language_models::BaseChatModel;
8use lc_prompts::PromptTemplate;
9use lc_providers::ProviderError;
10use lc_schema::Message;
11use lc_vector_stores::{Document, SearchResult};
12
13use crate::retriever::RetrieverTrait;
14use std::collections::HashSet;
15use std::sync::Arc;
16
17/// HyDE error type
18#[derive(Debug)]
19#[non_exhaustive]
20pub enum HyDEError {
21    /// LLM call error
22    LLMError(String),
23    /// Embedding error
24    EmbeddingError(String),
25    /// Base retriever error
26    RetrieverError(String),
27}
28
29impl std::fmt::Display for HyDEError {
30    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31        match self {
32            HyDEError::LLMError(msg) => write!(f, "LLM error: {}", msg),
33            HyDEError::EmbeddingError(msg) => write!(f, "embedding error: {}", msg),
34            HyDEError::RetrieverError(msg) => write!(f, "retrieval error: {}", msg),
35        }
36    }
37}
38
39impl std::error::Error for HyDEError {}
40
41/// HyDE configuration
42pub struct HyDEConfig {
43    /// Prompt used to generate the hypothetical document
44    pub prompt_template: String,
45
46    /// Number of documents to retrieve
47    pub k: usize,
48
49    /// Whether to include the original query results
50    pub include_original_query: bool,
51}
52
53impl Default for HyDEConfig {
54    fn default() -> Self {
55        Self {
56            prompt_template: DEFAULT_HYDE_PROMPT.to_string(),
57            k: 5,
58            include_original_query: true,
59        }
60    }
61}
62
63impl HyDEConfig {
64    /// Creates a `HyDEConfig` with default configuration
65    pub fn new() -> Self {
66        Self::default()
67    }
68
69    /// Sets the number of documents to retrieve
70    pub fn with_k(mut self, k: usize) -> Self {
71        self.k = k;
72        self
73    }
74
75    /// Sets the prompt template used to generate the hypothetical document
76    pub fn with_prompt(mut self, prompt: impl Into<String>) -> Self {
77        self.prompt_template = prompt.into();
78        self
79    }
80
81    /// Sets whether to include the original query results
82    pub fn with_include_original_query(mut self, include: bool) -> Self {
83        self.include_original_query = include;
84        self
85    }
86}
87
88const DEFAULT_HYDE_PROMPT: &str = r#"Please write a passage to answer the question.
89
90Question: {question}
91
92Passage:"#;
93
94/// HyDE Retriever
95///
96/// Workflow:
97/// 1. The user asks a question
98/// 2. The LLM generates a hypothetical document (an ideal answer)
99/// 3. The hypothetical document is embedded
100/// 4. The hypothetical document vector retrieves real documents
101/// 5. Returns the relevant documents
102pub struct HyDERetriever {
103    /// The LLM used to generate the hypothetical document
104    ///
105    /// P0-3: no longer hardcodes `OpenAIChat`; accepts any LLM implementing `BaseChatModel`.
106    llm: Arc<dyn BaseChatModel<Error = ProviderError> + Send + Sync>,
107    base_retriever: Arc<dyn RetrieverTrait>,
108    config: HyDEConfig,
109}
110
111impl HyDERetriever {
112    /// Creates a HyDERetriever (accepting any LLM implementing `BaseChatModel`)
113    ///
114    /// P0-3: removes the dead `_embeddings` parameter — embedding the hypothetical document
115    /// is handled internally by the `base_retriever`, so no external Embeddings is needed.
116    pub fn new<L>(llm: L, base_retriever: Arc<dyn RetrieverTrait>) -> Self
117    where
118        L: BaseChatModel + Send + Sync + 'static,
119        L::Error: Into<ProviderError>,
120    {
121        Self {
122            llm: lc_providers::wrap_chat_model(llm),
123            base_retriever,
124            config: HyDEConfig::default(),
125        }
126    }
127
128    /// P0-3: builds from an already-wrapped `Arc<dyn BaseChatModel<Error = ProviderError>>`
129    pub fn new_arc(
130        llm: Arc<dyn BaseChatModel<Error = ProviderError> + Send + Sync>,
131        base_retriever: Arc<dyn RetrieverTrait>,
132    ) -> Self {
133        Self {
134            llm,
135            base_retriever,
136            config: HyDEConfig::default(),
137        }
138    }
139
140    /// Sets the HyDE configuration
141    pub fn with_config(mut self, config: HyDEConfig) -> Self {
142        self.config = config;
143        self
144    }
145
146    /// Sets the number of documents to retrieve
147    pub fn with_k(mut self, k: usize) -> Self {
148        self.config.k = k;
149        self
150    }
151
152    /// Sets whether to include the original query results
153    pub fn with_include_original_query(mut self, include: bool) -> Self {
154        self.config.include_original_query = include;
155        self
156    }
157
158    async fn generate_hypothetical_document(&self, query: &str) -> Result<String, HyDEError> {
159        let template = PromptTemplate::new(&self.config.prompt_template);
160        let mut vars = std::collections::HashMap::new();
161        vars.insert("question", query);
162        let prompt = template
163            .format(&vars)
164            .unwrap_or_else(|_| self.config.prompt_template.clone());
165
166        let messages = vec![Message::human(prompt)];
167
168        let response = self
169            .llm
170            .invoke(messages, None)
171            .await
172            .map_err(|e| HyDEError::LLMError(e.to_string()))?;
173
174        Ok(response.content)
175    }
176
177    /// Retrieves documents via the HyDE flow: generates a hypothetical document first, then retrieves and merges deduped results
178    pub async fn retrieve(&self, query: &str) -> Result<Vec<Document>, HyDEError> {
179        let hyde_doc = self.generate_hypothetical_document(query).await?;
180
181        let mut all_docs = Vec::new();
182        let mut seen_content: HashSet<String> = HashSet::new();
183
184        let hyde_results = self
185            .base_retriever
186            .retrieve(&hyde_doc, self.config.k)
187            .await
188            .map_err(|e| HyDEError::RetrieverError(e.to_string()))?;
189
190        for doc in &hyde_results {
191            seen_content.insert(doc.content.clone());
192        }
193        all_docs.extend(hyde_results);
194
195        if self.config.include_original_query {
196            let query_results = self
197                .base_retriever
198                .retrieve(query, self.config.k)
199                .await
200                .map_err(|e| HyDEError::RetrieverError(e.to_string()))?;
201
202            for doc in query_results {
203                if seen_content.insert(doc.content.clone()) {
204                    all_docs.push(doc);
205                }
206            }
207        }
208
209        Ok(all_docs)
210    }
211
212    /// Retrieves documents with scores (HyDE flow)
213    pub async fn retrieve_with_scores(&self, query: &str) -> Result<Vec<SearchResult>, HyDEError> {
214        let hyde_doc = self.generate_hypothetical_document(query).await?;
215
216        let mut all_results: Vec<SearchResult> = Vec::new();
217        let mut seen_content: HashSet<String> = HashSet::new();
218
219        let hyde_results = self
220            .base_retriever
221            .retrieve_with_scores(&hyde_doc, self.config.k)
222            .await
223            .map_err(|e| HyDEError::RetrieverError(e.to_string()))?;
224
225        for r in &hyde_results {
226            seen_content.insert(r.document.content.clone());
227        }
228        all_results.extend(hyde_results);
229
230        if self.config.include_original_query {
231            let query_results = self
232                .base_retriever
233                .retrieve_with_scores(query, self.config.k)
234                .await
235                .map_err(|e| HyDEError::RetrieverError(e.to_string()))?;
236
237            for result in query_results {
238                if seen_content.insert(result.document.content.clone()) {
239                    all_results.push(result);
240                }
241            }
242        }
243
244        all_results.sort_by(|a, b| {
245            b.score
246                .partial_cmp(&a.score)
247                .unwrap_or(std::cmp::Ordering::Equal)
248        });
249
250        Ok(all_results)
251    }
252
253    /// Returns the LLM-generated hypothetical document (without retrieving)
254    pub async fn get_hypothetical_document(&self, query: &str) -> Result<String, HyDEError> {
255        self.generate_hypothetical_document(query).await
256    }
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262
263    #[test]
264    fn test_hyde_config_default() {
265        let config = HyDEConfig::default();
266
267        assert_eq!(config.k, 5);
268        assert!(config.include_original_query);
269        assert!(config.prompt_template.contains("{question}"));
270    }
271
272    #[test]
273    fn test_hyde_config_custom() {
274        let config = HyDEConfig::new()
275            .with_k(10)
276            .with_include_original_query(false);
277
278        assert_eq!(config.k, 10);
279        assert!(!config.include_original_query);
280    }
281
282    #[test]
283    fn test_hyde_config_prompt() {
284        let custom_prompt = "Answer this: {question}".to_string();
285        let config = HyDEConfig::new().with_prompt(custom_prompt.clone());
286
287        assert!(config.prompt_template.contains("{question}"));
288    }
289}