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}