Skip to main content

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}