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}