Skip to main content

context_forge/
builder.rs

1//! [`ContextForgeBuilder`] — opinionated construction path for [`crate::ContextForge`].
2
3use std::path::Path;
4use std::sync::Arc;
5
6use crate::config::Config;
7use crate::engine::ContextEngine;
8use crate::lexicon::{CompositeLexiconScorer, DefaultEnglishScorer, LexiconScorer};
9use crate::scrub::ScrubConfig;
10#[cfg(feature = "semantic")]
11use crate::semantic::{Embedder, FasEmbedder};
12use crate::storage::open_storage;
13use crate::traits::Result;
14use crate::ContextForge;
15
16/// Builder for [`ContextForge`].
17///
18/// **Lexicon scoring is opt-in.** By default the engine ranks on relevance
19/// (BM25, plus semantic when an embedding model is set) with no lexicon layer.
20/// Lexicon scoring applies a *query-independent* importance boost, which suits
21/// persona/importance use cases but degrades pure relevance retrieval — so it
22/// must be requested explicitly:
23///
24/// - [`with_default_english_scorer`](Self::with_default_english_scorer) enables
25///   the built-in [`DefaultEnglishScorer`] (plain-English commitment/
26///   confirmation/decision markers).
27/// - [`with_persona_scorer`](Self::with_persona_scorer) adds a domain scorer.
28///
29/// When both are set they compose additively via [`CompositeLexiconScorer`].
30///
31/// # Example
32///
33/// ```no_run
34/// use context_forge::{Config, ContextForge, ConfigLexiconScorer};
35/// use std::path::PathBuf;
36///
37/// #[tokio::main]
38/// async fn main() -> Result<(), context_forge::Error> {
39///     let mut config = Config::default();
40///     config.db_path = PathBuf::from("memory.db");
41///
42///     // Relevance only, no lexicon (default):
43///     let cf = ContextForge::builder(config.clone()).build().await?;
44///
45///     // Opt into the English importance scorer:
46///     let cf = ContextForge::builder(config.clone())
47///         .with_default_english_scorer()
48///         .build()
49///         .await?;
50///
51///     // English + a persona scorer loaded from a TOML string:
52///     let toml = "[affirmations]\npatterns = [\"for the emperor\"]";
53///     let persona: ConfigLexiconScorer = toml.parse()?;
54///     let cf = ContextForge::builder(config)
55///         .with_default_english_scorer()
56///         .with_persona_scorer(persona)
57///         .build()
58///         .await?;
59///
60///     Ok(())
61/// }
62/// ```
63pub struct ContextForgeBuilder {
64    config: Config,
65    english_defaults: bool,
66    persona_scorer: Option<Arc<dyn LexiconScorer>>,
67    #[cfg(feature = "semantic")]
68    embedding_cache_dir: Option<std::path::PathBuf>,
69    #[cfg(feature = "semantic")]
70    embedder: Option<Arc<dyn Embedder>>,
71}
72
73impl ContextForgeBuilder {
74    /// Create a new builder with the given config.
75    ///
76    /// Prefer [`ContextForge::builder`] over calling this directly.
77    #[must_use]
78    pub fn new(config: Config) -> Self {
79        Self {
80            config,
81            english_defaults: false,
82            persona_scorer: None,
83            #[cfg(feature = "semantic")]
84            embedding_cache_dir: None,
85            #[cfg(feature = "semantic")]
86            embedder: None,
87        }
88    }
89
90    /// Enable the built-in [`DefaultEnglishScorer`] (opt-in).
91    ///
92    /// Applies a query-independent importance boost to entries containing
93    /// plain-English commitment/confirmation/decision/correction markers
94    /// ("i'll fix it", "confirmed", "we decided", "never mind"). This is the
95    /// right signal for persona/importance use cases (surfacing what matters to
96    /// a user) but **hurts pure relevance retrieval** — a factual-QA benchmark
97    /// showed it lowering recall by burying evidence under important-*sounding*
98    /// distractors. Off by default for that reason; enable it when importance,
99    /// not just relevance, is what you want to rank on.
100    #[must_use]
101    pub fn with_default_english_scorer(mut self) -> Self {
102        self.english_defaults = true;
103        self
104    }
105
106    /// Add a persona scorer (opt-in).
107    ///
108    /// Typically a [`crate::ConfigLexiconScorer`] loaded from a domain-specific
109    /// TOML file. If [`with_default_english_scorer`](Self::with_default_english_scorer)
110    /// was also called, the two compose additively via [`CompositeLexiconScorer`]
111    /// and the engine applies a `-1.0` floor after fusion; otherwise the persona
112    /// scorer is used alone.
113    #[must_use]
114    pub fn with_persona_scorer(mut self, scorer: impl LexiconScorer + 'static) -> Self {
115        self.persona_scorer = Some(Arc::new(scorer));
116        self
117    }
118
119    /// Enable semantic search using the all-MiniLM-L6-v2 model.
120    ///
121    /// `cache_dir` is where fastembed stores the downloaded ONNX weights
122    /// (~22 MB). The model is downloaded automatically on first use; subsequent
123    /// starts load from the local cache.
124    ///
125    /// This is a convenience wrapper over
126    /// [`with_embedder`](Self::with_embedder) that constructs a [`FasEmbedder`]
127    /// at [`build`](Self::build) time. To reuse one already-loaded model across
128    /// many `ContextForge` instances, or to plug in a different backend, use
129    /// [`with_embedder`](Self::with_embedder) directly.
130    ///
131    /// Requires the `semantic` Cargo feature.
132    ///
133    /// [`FasEmbedder`]: crate::semantic::FasEmbedder
134    #[cfg(feature = "semantic")]
135    #[must_use]
136    pub fn with_embedding_model(mut self, cache_dir: impl AsRef<std::path::Path>) -> Self {
137        self.embedding_cache_dir = Some(cache_dir.as_ref().to_path_buf());
138        self
139    }
140
141    /// Inject an [`Embedder`](crate::semantic::Embedder) for semantic search.
142    ///
143    /// Accepts any implementation as an `Arc<dyn Embedder>`, so a single loaded
144    /// model can be shared across many builds (load once, clone the `Arc`), or a
145    /// custom/remote backend can be supplied. Takes precedence over
146    /// [`with_embedding_model`](Self::with_embedding_model) if both are set.
147    ///
148    /// Requires the `semantic` Cargo feature.
149    #[cfg(feature = "semantic")]
150    #[must_use]
151    pub fn with_embedder(mut self, embedder: Arc<dyn Embedder>) -> Self {
152        self.embedder = Some(embedder);
153        self
154    }
155
156    /// Open the database and build a [`ContextForge`] with the configured scorers.
157    ///
158    /// Lexicon scoring is applied only if
159    /// [`with_default_english_scorer`](Self::with_default_english_scorer) and/or
160    /// [`with_persona_scorer`](Self::with_persona_scorer) were called; with both,
161    /// they compose via [`CompositeLexiconScorer`]. With neither, the engine ranks
162    /// on relevance (BM25 + semantic) only.
163    ///
164    /// # Errors
165    ///
166    /// Returns an error if the database cannot be opened or migrations fail.
167    pub async fn build(self) -> Result<ContextForge> {
168        let db_path = self.config.db_path.clone();
169        let max_entries = self.config.max_entries;
170        let scrub_config: ScrubConfig = self.config.scrub.clone();
171
172        let (storage, searcher) = open_storage(Path::new(&db_path), max_entries).await?;
173
174        // Compose only the opted-in scorers; default is none (relevance only).
175        let mut scorers: Vec<Arc<dyn LexiconScorer>> = Vec::new();
176        if self.english_defaults {
177            scorers.push(Arc::new(DefaultEnglishScorer::default()));
178        }
179        if let Some(persona) = self.persona_scorer {
180            scorers.push(persona);
181        }
182        let scorer: Option<Arc<dyn LexiconScorer>> = match scorers.len() {
183            0 => None,
184            1 => scorers.pop(),
185            _ => Some(Arc::new(CompositeLexiconScorer::new(scorers))),
186        };
187
188        #[cfg_attr(not(feature = "semantic"), allow(unused_mut))]
189        let mut engine = ContextEngine::new(Box::new(storage), Box::new(searcher), self.config);
190        if let Some(scorer) = scorer {
191            engine = engine.with_scorer(scorer);
192        }
193
194        // A directly-injected embedder wins; otherwise construct a FasEmbedder
195        // from the configured cache dir if one was set.
196        #[cfg(feature = "semantic")]
197        {
198            let embedder: Option<Arc<dyn Embedder>> = match self.embedder {
199                Some(e) => Some(e),
200                None => match self.embedding_cache_dir {
201                    Some(cache_dir) => Some(Arc::new(FasEmbedder::new(&cache_dir)?)),
202                    None => None,
203                },
204            };
205            if let Some(embedder) = embedder {
206                engine = engine.with_embedder(embedder);
207            }
208        }
209
210        Ok(ContextForge::from_parts(engine, scrub_config))
211    }
212}