klieo_embed_common/lib.rs
1#![deny(missing_docs)]
2#![deny(rust_2018_idioms)]
3#![deny(rustdoc::broken_intra_doc_links)]
4
5//! Shared [`Embedder`] trait + non-ranking/fake implementations for klieo
6//! memory backends.
7//!
8//! Before W3.A17 the trait was duplicated byte-for-byte across
9//! `klieo-memory-sqlite::embedder` and `klieo-memory-qdrant::embedder`
10//! — any downstream embedder (Ollama, OpenAI, fastembed) had to
11//! `impl Embedder` twice. This crate is the single home; both memory
12//! crates re-export from here to keep their public APIs source-stable.
13//!
14//! # Features
15//!
16//! - **Default** — `Embedder` trait + `NonRankingEmbedder` (zero vectors).
17//! - **`test-utils`** — adds `FakeEmbedder` (deterministic per-text
18//! hashing) for downstream test harnesses.
19//! - **`fastembed`** — adds `FastEmbedEmbedder` (fastembed-rs ONNX CPU
20//! embeddings). Heavy; pulls the ONNX runtime.
21
22use async_trait::async_trait;
23use klieo_core::error::MemoryError;
24use klieo_core::memory::RecallSemantics;
25
26#[cfg(feature = "fastembed")]
27mod fastembed;
28#[cfg(feature = "fastembed")]
29pub use fastembed::{FastEmbedEmbedder, DEFAULT_DIM, DEFAULT_MODEL};
30
31/// Compute embeddings for one or more texts.
32///
33/// Output vectors must each be of length [`Embedder::dimension`].
34/// Implementations must be deterministic at the type level — the
35/// dimensionality cannot vary across calls on the same instance.
36#[async_trait]
37pub trait Embedder: Send + Sync {
38 /// Embedding dimensionality. Must be constant for a given
39 /// `Embedder` instance — long-term memory backends reject vectors
40 /// of the wrong length at runtime.
41 fn dimension(&self) -> usize;
42
43 /// Compute one embedding per input text.
44 async fn embed(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, MemoryError>;
45
46 /// Whether distinct inputs produce distinct directions, i.e. whether a
47 /// vector store wired with this embedder actually ranks by relevance.
48 ///
49 /// Defaults to `true` — a real embedding model needs no ceremony. Only
50 /// [`NonRankingEmbedder`] and its like override it, which is what lets a
51 /// store report [`klieo_core::memory::RecallSemantics::NonRanking`]
52 /// instead of claiming vector recall it cannot deliver.
53 fn is_ranking(&self) -> bool {
54 true
55 }
56}
57
58/// What a nearest-neighbour store backed by `embedder` can honestly claim as
59/// its [`RecallSemantics`].
60///
61/// A vector backend is only a semantic substrate when its embedder separates
62/// distinct inputs; wired with [`NonRankingEmbedder`] the same backend stores,
63/// retrieves and ranks by nothing. Every `LongTermMemory` in this workspace
64/// whose ranking comes from an injected embedder answers
65/// `LongTermMemory::recall_semantics` through this function.
66pub fn vector_recall_semantics(embedder: &dyn Embedder) -> RecallSemantics {
67 if embedder.is_ranking() {
68 RecallSemantics::Vector
69 } else {
70 RecallSemantics::NonRanking
71 }
72}
73
74/// Zero-vector embedder: stores and retrieves facts, ranks nothing.
75///
76/// Every text embeds to the same all-zero vector, so cosine similarity is
77/// always 1.0 (or undefined) and recall degenerates to insertion order. It
78/// exists so the capability-shaped constructors — `MemorySqlite::open`,
79/// `MemoryQdrant::connect`, `MemoryPgvector::connect` — can hand back a
80/// working store with no embedding model present.
81///
82/// **It is not a semantic substrate.** Two adopters shipped production-shaped
83/// vector tiers on it and measured substring-grade recall before noticing. The
84/// safeguards, all of which the old `DummyEmbedder` name lacked:
85///
86/// - the name says what it does,
87/// - the first [`Embedder::embed`] call logs a `tracing::warn!` once, and
88/// - [`Embedder::is_ranking`] answers `false`, so a store built on it reports
89/// [`klieo_core::memory::RecallSemantics::NonRanking`] and a pipeline that
90/// asserts semantic recall at startup fails there instead of in a
91/// measurement.
92pub struct NonRankingEmbedder;
93
94/// Legacy name for [`NonRankingEmbedder`], behaving identically.
95///
96/// A separate unit struct rather than `pub use NonRankingEmbedder as
97/// DummyEmbedder`, because **rustc silently ignores `#[deprecated]` on a
98/// re-export**: downstream code kept compiling with no warning at all, so the
99/// alias would have announced a migration it never actually prompted. A real
100/// deprecated item warns in both type and value position, which is what
101/// `Arc::new(DummyEmbedder)` call sites need.
102#[deprecated(
103 since = "3.11.0",
104 note = "renamed to NonRankingEmbedder — the old name did not say that recall is unranked"
105)]
106pub struct DummyEmbedder;
107
108#[allow(deprecated)]
109#[async_trait]
110impl Embedder for DummyEmbedder {
111 fn dimension(&self) -> usize {
112 NonRankingEmbedder.dimension()
113 }
114
115 async fn embed(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, MemoryError> {
116 NonRankingEmbedder.embed(texts).await
117 }
118
119 fn is_ranking(&self) -> bool {
120 NonRankingEmbedder.is_ranking()
121 }
122}
123
124/// Dimensionality [`NonRankingEmbedder`] claims, matching the common
125/// 384-dim sentence-transformer default so a store provisioned with it can be
126/// re-pointed at a real model without a collection migration.
127const NON_RANKING_DIM: usize = 384;
128
129#[async_trait]
130impl Embedder for NonRankingEmbedder {
131 fn dimension(&self) -> usize {
132 NON_RANKING_DIM
133 }
134
135 async fn embed(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, MemoryError> {
136 static WARNED: std::sync::Once = std::sync::Once::new();
137 WARNED.call_once(|| {
138 tracing::warn!(
139 embedder = "NonRankingEmbedder",
140 "embedding with zero vectors — recall returns k facts in arbitrary order, \
141 not by relevance; wire a real Embedder (e.g. FastEmbedEmbedder) for \
142 semantic retrieval"
143 );
144 });
145 Ok(texts
146 .iter()
147 .map(|_| vec![0.0f32; NON_RANKING_DIM])
148 .collect())
149 }
150
151 fn is_ranking(&self) -> bool {
152 false
153 }
154}
155
156/// Test-only embedder that hashes each input text into a deterministic
157/// vector. Identical texts produce identical embeddings, so cosine
158/// recall behaves predictably under test.
159#[cfg(any(test, feature = "test-utils"))]
160pub struct FakeEmbedder {
161 dim: usize,
162}
163
164#[cfg(any(test, feature = "test-utils"))]
165impl FakeEmbedder {
166 /// Build a deterministic embedder of the given dimensionality.
167 pub fn new(dim: usize) -> Self {
168 Self { dim }
169 }
170}
171
172#[cfg(any(test, feature = "test-utils"))]
173impl Default for FakeEmbedder {
174 fn default() -> Self {
175 Self::new(8)
176 }
177}
178
179#[cfg(any(test, feature = "test-utils"))]
180#[async_trait]
181impl Embedder for FakeEmbedder {
182 fn dimension(&self) -> usize {
183 self.dim
184 }
185
186 async fn embed(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, MemoryError> {
187 let dim = self.dim;
188 Ok(texts
189 .iter()
190 .map(|text| {
191 // Deterministic per-text vector via FNV-1a per slot —
192 // toolchain-stable across rustc/std hasher upgrades.
193 let mut v = vec![0.0f32; dim];
194 let bytes = text.as_bytes();
195 for (i, slot) in v.iter_mut().enumerate() {
196 const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
197 const FNV_PRIME: u64 = 0x0000_0001_0000_01b3;
198 let mut h: u64 = FNV_OFFSET;
199 h ^= i as u64;
200 h = h.wrapping_mul(FNV_PRIME);
201 for &b in bytes {
202 h ^= b as u64;
203 h = h.wrapping_mul(FNV_PRIME);
204 }
205 *slot = (h as f32 / u64::MAX as f32) - 0.5;
206 }
207 let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
208 if norm > 0.0 {
209 for x in &mut v {
210 *x /= norm;
211 }
212 }
213 v
214 })
215 .collect())
216 }
217}
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222
223 #[tokio::test]
224 async fn non_ranking_returns_zero_vectors_of_dim_384() {
225 let e = NonRankingEmbedder;
226 assert_eq!(e.dimension(), 384);
227 let out = e.embed(&["a".into(), "b".into()]).await.unwrap();
228 assert_eq!(out.len(), 2);
229 assert_eq!(out[0].len(), 384);
230 assert!(out[0].iter().all(|x| *x == 0.0));
231 }
232
233 #[tokio::test]
234 async fn non_ranking_declares_itself_unranked_and_fake_does_not() {
235 assert!(!NonRankingEmbedder.is_ranking());
236 assert!(FakeEmbedder::new(8).is_ranking());
237 }
238
239 /// The legacy name must keep behaving identically — a rename that changed
240 /// behaviour under the old name would be worse than no alias at all.
241 ///
242 /// That it still *warns* is a compile-time diagnostic and is not asserted
243 /// here; it is a property of `DummyEmbedder` being a deprecated item
244 /// rather than a deprecated re-export, which rustc ignores. Turning it
245 /// back into a re-export would silently un-deprecate it.
246 #[tokio::test]
247 #[allow(deprecated)]
248 async fn the_deprecated_alias_still_behaves_identically() {
249 let legacy = DummyEmbedder;
250 assert_eq!(legacy.dimension(), NonRankingEmbedder.dimension());
251 assert_eq!(legacy.is_ranking(), NonRankingEmbedder.is_ranking());
252 assert_eq!(
253 legacy.embed(&["a".into()]).await.unwrap(),
254 NonRankingEmbedder.embed(&["a".into()]).await.unwrap()
255 );
256 }
257
258 #[tokio::test]
259 async fn fake_embedder_is_deterministic() {
260 let e = FakeEmbedder::new(16);
261 let a = e.embed(&["hello".into()]).await.unwrap();
262 let b = e.embed(&["hello".into()]).await.unwrap();
263 assert_eq!(a, b);
264 }
265
266 #[tokio::test]
267 async fn fake_embedder_distinguishes_inputs() {
268 let e = FakeEmbedder::new(16);
269 let a = e.embed(&["alpha".into()]).await.unwrap();
270 let b = e.embed(&["beta".into()]).await.unwrap();
271 assert_ne!(a, b);
272 }
273
274 #[tokio::test]
275 async fn fake_embedder_outputs_unit_vectors() {
276 let e = FakeEmbedder::new(8);
277 let v = e.embed(&["hello".into()]).await.unwrap();
278 let norm: f32 = v[0].iter().map(|x| x * x).sum::<f32>().sqrt();
279 assert!(
280 (norm - 1.0).abs() < 1e-5,
281 "fake embedder must produce unit vectors, got norm={norm}"
282 );
283 }
284}