gemini_memory_rs/retrieval/
embedding.rs

1//! The text a record should be embedded as.
2//!
3//! [`SemanticFallback`](super::SemanticFallback) is a trait: the engine ranks
4//! and fuses, and the caller supplies the vector search. That leaves one
5//! decision entirely to the implementor — *what text goes into the embedder* —
6//! and it turns out to matter more than any other choice in the semantic layer,
7//! including the model and the number of dimensions.
8//!
9//! So the answer lives here rather than in a caller's head.
10//!
11//! # What was measured
12//!
13//! Six candidate texts, over 1,199 records and 93 questions phrased as people
14//! actually speak rather than as the corpus is written
15//! (`tests/semantic_fusion_probe.rs`):
16//!
17//! | what was embedded | top-1 |
18//! |---|---|
19//! | the statement alone | 41/93 |
20//! | statement + hand-written aliases and tags | 57/93 |
21//! | statement + the predicate line | 53/93 |
22//! | **statement + the whole frontmatter as prose** | **66/93** |
23//! | statement + six LLM-written questions | 31/93 |
24//! | all of the above together | 52/93 |
25//!
26//! The winner is [`embedding_text`], and the thing to notice is that it costs
27//! nothing: no model call, no second pass at ingestion, no author. It is the
28//! fields the record already carries, written as a few lines of prose.
29//!
30//! It also beats the hand-written aliases — which were composed by someone who
31//! had seen the question set — by nine questions.
32//!
33//! # Why it works
34//!
35//! A statement gives the *value* and only implies the *attribute*. "The user's
36//! usual coffee order is a cortado" answers a question about coffee orders
37//! without ever containing the words "coffee order" in the way a question asks
38//! them. Naming the attribute outright is worth 12 of the 25 points (41 → 53);
39//! the subject, entities and temporal scope are worth the other 13 (53 → 66).
40//!
41//! Every one of those fields carries retrievable signal the statement had left
42//! implicit. It is not that structured boilerplate flatters the geometry.
43//!
44//! # Why not to enrich further
45//!
46//! Two separate experiments say adding model-written text makes this worse, and
47//! they agree on the reason. LLM-written *questions* score 31/93, below the bare
48//! statement; LLM-written short alias *terms* score worse than leaving the alias
49//! field empty (`tests/alias_terms_probe.rs`).
50//!
51//! The shape was never the variable. Both were applied to every record, and
52//! uniform enrichment is what fails: a synonym added to one record is a rare,
53//! discriminating term, while the same synonym added to all of them has no IDF
54//! left and discriminates nothing — having lengthened a length-normalised field
55//! everywhere on the way. Selective enrichment may well pay; nothing here can
56//! target it, and ingestion is the wrong place to try.
57
58use crate::core::CanonicalMemory;
59
60/// The text to embed for a record.
61///
62/// The statement, followed by the record's own frontmatter written as prose.
63/// This is the measured-best input to a dense retriever for this corpus — see
64/// the module docs for the comparison it won.
65///
66/// Use this in a [`SemanticFallback`](super::SemanticFallback) implementation
67/// so that what is indexed matches what was measured:
68///
69/// ```
70/// # use gemini_memory_rs::core::{CanonicalMemory, MemoryKind, UserId};
71/// # use gemini_memory_rs::retrieval::embedding_text;
72/// # fn example(records: &[CanonicalMemory]) {
73/// for record in records {
74///     let text = embedding_text(record);
75///     // embed(&text) and store the vector against record.id
76/// }
77/// # }
78/// ```
79pub fn embedding_text(memory: &CanonicalMemory) -> String {
80    format!("{}\n{}", memory.statement, frontmatter_prose(memory))
81}
82
83/// The frontmatter alone, as the prose [`embedding_text`] appends.
84///
85/// Separated because a caller embedding something other than the statement —
86/// a summary, a window of turns — still wants the structured lines, and
87/// because it is what the ablation in the module docs isolates.
88pub fn frontmatter_prose(memory: &CanonicalMemory) -> String {
89    let mut lines = vec![
90        format!("About: {}", memory.subject.display),
91        predicate_line(memory),
92    ];
93    if !memory.retrieval.entities.is_empty() {
94        lines.push(format!(
95            "Mentions: {}",
96            memory.retrieval.entities.join(", ")
97        ));
98    }
99    if let Some(location) = &memory.retrieval.location {
100        lines.push(format!("Place: {location}"));
101    }
102    if let Some(qualifier) = &memory.qualifier {
103        lines.push(format!("When: {qualifier}"));
104    }
105    lines.push(format!("Holds: {:?}", memory.temporal_scope));
106    lines.join("\n")
107}
108
109/// The line naming the attribute this record is about.
110///
111/// The single highest-value line in the whole rendering: adding it alone moves
112/// top-1 from 41 to 53 of 93. A question asks by the attribute; a statement
113/// only implies it.
114pub fn predicate_line(memory: &CanonicalMemory) -> String {
115    format!(
116        "Kind: {:?} {}",
117        memory.kind,
118        memory.predicate.as_str().replace('_', " ")
119    )
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125    use chrono::Utc;
126
127    use crate::core::{
128        CanonicalPredicate, EntityRef, Explicitness, MemoryId, MemoryKind, MemorySource,
129        MemoryValue, RetrievalMetadata, SessionId, TemporalMetadata, TemporalScope, TurnId, UserId,
130    };
131
132    fn record() -> CanonicalMemory {
133        CanonicalMemory {
134            id: MemoryId::new("mem_coffee"),
135            owner: UserId::new("usr_test"),
136            kind: MemoryKind::Preference,
137            predicate: CanonicalPredicate::new("beverage_preference"),
138            status: crate::core::MemoryStatus::Active,
139            confidence: 0.9,
140            subject: EntityRef::named("user"),
141            value: MemoryValue::Text("cortado".into()),
142            statement: "The user's usual coffee order is a cortado.".into(),
143            evidence_summary: "stated".into(),
144            source: MemorySource::from_explicitness(
145                Explicitness::ExplicitStatement,
146                SessionId::new("ses_1"),
147                TurnId(1),
148            ),
149            temporal: TemporalMetadata::created_at(Utc::now()),
150            retrieval: RetrievalMetadata {
151                subject: "user".into(),
152                entities: vec!["cortado".into()],
153                ..Default::default()
154            },
155            temporal_scope: TemporalScope::Persistent,
156            qualifier: None,
157            evidence: Default::default(),
158            privacy: Default::default(),
159            supersedes: Vec::new(),
160            superseded_by: None,
161        }
162    }
163
164    #[test]
165    fn the_attribute_is_named_even_though_the_statement_only_implies_it() {
166        let memory = record();
167        let text = embedding_text(&memory);
168        assert!(
169            text.contains("beverage preference"),
170            "the predicate must appear in readable words, not as a symbol: {text}"
171        );
172        assert!(
173            text.contains("The user's usual coffee order is a cortado."),
174            "the statement must survive: {text}"
175        );
176    }
177
178    #[test]
179    fn underscores_become_spaces_so_the_embedder_sees_words() {
180        let memory = record();
181        assert!(!predicate_line(&memory).contains('_'));
182    }
183
184    #[test]
185    fn absent_fields_leave_no_empty_lines() {
186        let memory = record();
187        let prose = frontmatter_prose(&memory);
188        assert!(
189            !prose.lines().any(str::is_empty),
190            "a missing location or qualifier should omit its line entirely: {prose:?}"
191        );
192        assert!(!prose.contains("Place:"), "no location was set");
193        assert!(!prose.contains("When:"), "no qualifier was set");
194    }
195}