1use std::time::Duration;
2
3use anyhow::{bail, Context, Result};
4use serde::{Deserialize, Serialize};
5use sha2::{Digest, Sha256};
6
7mod config;
8mod local_semantic;
9mod status;
10
11use config::env_value;
12pub(crate) use config::resolve_embedding_config;
13use local_semantic::LocalEmbeddingInputKind;
14pub use local_semantic::{
15 LocalEmbeddingDownloadReport, LocalEmbeddingInventoryReport, LocalEmbeddingModelInventory,
16};
17pub(crate) use status::is_embedding_provider_off_error;
18
19pub const FEATURE_HASH_EMBEDDING_DIMENSIONS: usize = 768;
20pub const FEATURE_HASH_EMBEDDING_MODEL: &str = "remem-local-feature-hash-v1";
21pub const LOCAL_EMBEDDING_DIMENSIONS: usize = FEATURE_HASH_EMBEDDING_DIMENSIONS;
22pub const LOCAL_EMBEDDING_MODEL: &str = FEATURE_HASH_EMBEDDING_MODEL;
23
24const DEFAULT_PROVIDER: EmbeddingProvider = EmbeddingProvider::Auto;
25const OPENAI_DEFAULT_BASE_URL: &str = "https://api.openai.com/v1";
26const OPENAI_DEFAULT_MODEL: &str = "text-embedding-3-small";
27const DEFAULT_API_KEY_ENV: &str = "OPENAI_API_KEY";
28const DEFAULT_TIMEOUT_SECS: u64 = 30;
29
30const ENV_PROVIDER: &str = "REMEM_EMBEDDINGS_PROVIDER";
31const ENV_PROVIDER_LEGACY: &str = "REMEM_EMBEDDING_PROVIDER";
32const ENV_MODEL: &str = "REMEM_EMBEDDINGS_MODEL";
33const ENV_MODEL_LEGACY: &str = "REMEM_EMBEDDING_MODEL";
34const ENV_BASE_URL: &str = "REMEM_EMBEDDINGS_BASE_URL";
35const ENV_BASE_URL_LEGACY: &str = "REMEM_EMBEDDING_BASE_URL";
36const ENV_DIMENSIONS: &str = "REMEM_EMBEDDINGS_DIMENSIONS";
37const ENV_DIMENSIONS_LEGACY: &str = "REMEM_EMBEDDING_DIMENSIONS";
38const ENV_API_KEY: &str = "REMEM_EMBEDDINGS_API_KEY";
39const ENV_API_KEY_LEGACY: &str = "REMEM_EMBEDDING_API_KEY";
40const ENV_API_KEY_ENV: &str = "REMEM_EMBEDDINGS_API_KEY_ENV";
41const ENV_TIMEOUT_SECS: &str = "REMEM_EMBEDDINGS_TIMEOUT_SECS";
42const ENV_FALLBACK: &str = "REMEM_EMBEDDINGS_FALLBACK";
43const ENV_MODEL_DIR: &str = "REMEM_EMBEDDINGS_MODEL_DIR";
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum EmbeddingProvider {
47 Auto,
48 Local,
49 FeatureHash,
50 OpenAi,
51 Off,
52}
53
54impl EmbeddingProvider {
55 fn parse(raw: &str) -> Result<Self> {
56 match raw.trim().to_ascii_lowercase().as_str() {
57 "auto" => Ok(Self::Auto),
58 "local" => Ok(Self::Local),
59 "feature-hash" | "feature_hash" | "offline" => Ok(Self::FeatureHash),
60 "api" | "openai" | "openai-compatible" | "openai_compatible" => Ok(Self::OpenAi),
61 "off" | "disabled" | "none" => Ok(Self::Off),
62 other => bail!("unknown embeddings.provider: {other}"),
63 }
64 }
65
66 pub fn label(self) -> &'static str {
67 match self {
68 Self::Auto => "auto",
69 Self::Local => "local",
70 Self::FeatureHash => "feature-hash",
71 Self::OpenAi => "api",
72 Self::Off => "off",
73 }
74 }
75}
76
77#[derive(Debug, Clone, PartialEq, Eq)]
78pub struct EmbeddingConfig {
79 pub provider: EmbeddingProvider,
80 pub fallback: Option<EmbeddingProvider>,
81 pub model: String,
82 pub base_url: String,
83 pub dimensions: Option<usize>,
84 pub api_key_env: String,
85 pub model_dir: Option<String>,
86 pub timeout_secs: u64,
87}
88
89impl Default for EmbeddingConfig {
90 fn default() -> Self {
91 Self {
92 provider: DEFAULT_PROVIDER,
93 fallback: None,
94 model: OPENAI_DEFAULT_MODEL.to_string(),
95 base_url: OPENAI_DEFAULT_BASE_URL.to_string(),
96 dimensions: None,
97 api_key_env: DEFAULT_API_KEY_ENV.to_string(),
98 model_dir: None,
99 timeout_secs: DEFAULT_TIMEOUT_SECS,
100 }
101 }
102}
103
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct EmbeddingProviderStatus {
106 pub configured_provider: String,
107 pub fallback_provider: Option<String>,
108 pub active_provider: String,
109 pub active_model_id: Option<String>,
110 pub active_dimensions: Option<usize>,
111 pub degraded: bool,
112 pub disabled: bool,
113 pub unavailable_reason: Option<String>,
114 pub degradation_reason: Option<String>,
115 pub model_dir: Option<String>,
116}
117
118#[derive(Debug, Clone, PartialEq)]
119pub struct TextEmbedding {
120 model: String,
121 values: Vec<f32>,
122}
123
124impl TextEmbedding {
125 pub fn new(model: impl Into<String>, values: Vec<f32>) -> Result<Self> {
126 let model = model.into();
127 if model.trim().is_empty() {
128 bail!("embedding model must not be empty");
129 }
130 validate_embedding_values(&values)?;
131 Ok(Self { model, values })
132 }
133
134 pub fn model(&self) -> &str {
135 &self.model
136 }
137
138 pub fn values(&self) -> &[f32] {
139 &self.values
140 }
141
142 pub fn dimensions(&self) -> usize {
143 self.values.len()
144 }
145
146 pub fn profile(&self) -> EmbeddingProfile<'_> {
147 EmbeddingProfile {
148 model: &self.model,
149 dimensions: self.values.len(),
150 }
151 }
152}
153
154#[derive(Debug, Clone, Copy, PartialEq, Eq)]
155pub struct EmbeddingProfile<'a> {
156 pub model: &'a str,
157 pub dimensions: usize,
158}
159
160#[derive(Debug, Clone, PartialEq, Eq)]
161pub struct EmbeddingBackfillTarget {
162 pub model: String,
163 pub dimensions: usize,
164}
165
166#[derive(Debug, Default)]
167pub(crate) struct EmbeddingFallbackCache {
168 call_failure_fallback: Option<EmbeddingProvider>,
169 call_failure_fallback_target: Option<EmbeddingBackfillTarget>,
170}
171
172impl EmbeddingFallbackCache {
173 pub(crate) fn call_failure_fallback_target(&self) -> Option<EmbeddingBackfillTarget> {
174 match self.call_failure_fallback {
175 Some(EmbeddingProvider::Local) => self.call_failure_fallback_target.clone(),
176 Some(EmbeddingProvider::FeatureHash) => Some(EmbeddingBackfillTarget {
177 model: FEATURE_HASH_EMBEDDING_MODEL.to_string(),
178 dimensions: FEATURE_HASH_EMBEDDING_DIMENSIONS,
179 }),
180 Some(EmbeddingProvider::Auto)
181 | Some(EmbeddingProvider::OpenAi)
182 | Some(EmbeddingProvider::Off) => None,
183 None => None,
184 }
185 }
186}
187
188pub fn embed_query(query: &str) -> Result<TextEmbedding> {
189 embed_text(query, LocalEmbeddingInputKind::Query)
190}
191
192pub(crate) fn embed_query_with_fallback_cache(
193 query: &str,
194 cache: &mut EmbeddingFallbackCache,
195) -> Result<TextEmbedding> {
196 embed_text_with_fallback_cache(query, LocalEmbeddingInputKind::Query, cache)
197}
198
199pub(crate) fn embed_query_if_enabled(query: &str) -> Result<Option<TextEmbedding>> {
200 match embed_query(query) {
201 Ok(embedding) => Ok(Some(embedding)),
202 Err(error) if is_embedding_provider_off_error(&error) => Ok(None),
203 Err(error) => Err(error),
204 }
205}
206
207pub fn embed_memory(
208 title: &str,
209 content: &str,
210 memory_type: &str,
211 topic_key: Option<&str>,
212) -> Result<TextEmbedding> {
213 let text = memory_embedding_text(title, content, memory_type, topic_key);
214 embed_text(&text, LocalEmbeddingInputKind::Passage)
215}
216
217pub(crate) fn embed_memory_with_fallback_cache(
218 title: &str,
219 content: &str,
220 memory_type: &str,
221 topic_key: Option<&str>,
222 cache: &mut EmbeddingFallbackCache,
223) -> Result<TextEmbedding> {
224 let text = memory_embedding_text(title, content, memory_type, topic_key);
225 embed_text_with_fallback_cache(&text, LocalEmbeddingInputKind::Passage, cache)
226}
227
228pub fn embed_query_text_local(query: &str) -> Vec<f32> {
229 embed_text_local(query)
230}
231
232pub fn embed_memory_text_local(
233 title: &str,
234 content: &str,
235 memory_type: &str,
236 topic_key: Option<&str>,
237) -> Vec<f32> {
238 embed_text_local(&memory_embedding_text(
239 title,
240 content,
241 memory_type,
242 topic_key,
243 ))
244}
245
246pub fn embedding_content_hash(
247 title: &str,
248 content: &str,
249 memory_type: &str,
250 topic_key: Option<&str>,
251) -> String {
252 let mut hasher = Sha256::new();
253 hasher.update(memory_type.as_bytes());
254 hasher.update([0]);
255 if let Some(topic_key) = topic_key {
256 hasher.update(topic_key.as_bytes());
257 }
258 hasher.update([0]);
259 hasher.update(title.as_bytes());
260 hasher.update([0]);
261 hasher.update(content.as_bytes());
262 let digest = hasher.finalize();
263 digest.iter().map(|byte| format!("{byte:02x}")).collect()
264}
265
266pub(crate) fn configured_backfill_target() -> Result<EmbeddingBackfillTarget> {
267 let mut cache = EmbeddingFallbackCache::default();
268 configured_backfill_target_with_fallback_cache(&mut cache)
269}
270
271pub(crate) fn configured_backfill_target_with_fallback_cache(
272 cache: &mut EmbeddingFallbackCache,
273) -> Result<EmbeddingBackfillTarget> {
274 #[cfg(test)]
275 let _test_env_guard = config::lock_test_env();
276 let status = embedding_provider_status_without_probe()?;
277 if let Some(error) = disabled_provider_status_error(&status) {
278 return Err(error);
279 }
280 let probe = embed_text_with_fallback_cache(
281 "remem embedding profile probe",
282 LocalEmbeddingInputKind::Generic,
283 cache,
284 )?;
285 Ok(EmbeddingBackfillTarget {
286 model: probe.model().to_string(),
287 dimensions: probe.dimensions(),
288 })
289}
290
291pub fn embedding_provider_status() -> Result<EmbeddingProviderStatus> {
292 #[cfg(test)]
293 let _test_env_guard = config::lock_test_env();
294 let config = resolve_embedding_config()?;
295 let mut status = status::resolve_provider_status(&config);
296 status::probe_active_api_profile(&config, &mut status);
297 Ok(status)
298}
299
300pub(crate) fn embedding_provider_status_without_probe() -> Result<EmbeddingProviderStatus> {
301 #[cfg(test)]
302 let _test_env_guard = config::lock_test_env();
303 let config = resolve_embedding_config()?;
304 Ok(status::resolve_provider_status(&config))
305}
306
307pub(crate) fn disabled_provider_status_error(
308 status: &EmbeddingProviderStatus,
309) -> Option<anyhow::Error> {
310 if !status.disabled {
311 return None;
312 }
313 status
314 .degradation_reason
315 .clone()
316 .or_else(|| status.unavailable_reason.clone())
317 .map(status::embedding_provider_off_error_with_cause)
318 .or_else(|| Some(status::embedding_provider_off_error()))
319}
320
321pub(crate) fn provider_disabled_or_error() -> Result<bool> {
322 let status = embedding_provider_status_without_probe()?;
323 match disabled_provider_status_error(&status) {
324 Some(error) if is_embedding_provider_off_error(&error) => Ok(true),
325 Some(error) => Err(error),
326 None => Ok(false),
327 }
328}
329
330pub(crate) fn configured_local_embedding_model_id(config: &EmbeddingConfig) -> Result<String> {
331 local_semantic::configured_model_id(config)
332}
333
334pub fn download_local_embedding_model(model: Option<&str>) -> Result<LocalEmbeddingDownloadReport> {
335 local_semantic::download_model(model)
336}
337
338pub fn local_embedding_inventory() -> Result<LocalEmbeddingInventoryReport> {
339 local_semantic::inventory()
340}
341
342pub(crate) fn is_local_embedding_model_unavailable_error(error: &anyhow::Error) -> bool {
343 local_semantic::is_model_unavailable_error(error)
344}
345
346fn embed_text(text: &str, kind: LocalEmbeddingInputKind) -> Result<TextEmbedding> {
347 let mut cache = EmbeddingFallbackCache::default();
348 embed_text_with_fallback_cache(text, kind, &mut cache)
349}
350
351fn embed_text_with_fallback_cache(
352 text: &str,
353 kind: LocalEmbeddingInputKind,
354 cache: &mut EmbeddingFallbackCache,
355) -> Result<TextEmbedding> {
356 #[cfg(test)]
357 let _test_env_guard = config::lock_test_env();
358 let config = resolve_embedding_config()?;
359 if let Some(fallback) = cache.call_failure_fallback {
360 return embed_with_cached_call_failure_fallback(text, kind, &config, fallback);
361 }
362 match active_provider(&config)? {
363 ActiveEmbeddingProvider::Local => local_semantic::embed_text(text, &config, kind),
364 ActiveEmbeddingProvider::FeatureHash => {
365 TextEmbedding::new(FEATURE_HASH_EMBEDDING_MODEL, embed_text_local(text))
366 }
367 ActiveEmbeddingProvider::OpenAi { api_key } => embed_openai(text, &config, &api_key)
368 .or_else(|error| embed_with_call_failure_fallback(text, kind, &config, error, cache)),
369 ActiveEmbeddingProvider::Off => Err(status::embedding_provider_off_error()),
370 }
371}
372
373fn embed_with_cached_call_failure_fallback(
374 text: &str,
375 kind: LocalEmbeddingInputKind,
376 config: &EmbeddingConfig,
377 fallback: EmbeddingProvider,
378) -> Result<TextEmbedding> {
379 let fallback_runtime = status::provider_runtime(config, fallback);
380 if let Some(reason) = fallback_runtime.unavailable_reason {
381 bail!(
382 "cached embedding fallback {} unavailable: {reason}",
383 fallback.label()
384 );
385 }
386 match fallback_runtime.provider {
387 EmbeddingProvider::Local => local_semantic::embed_text(text, config, kind),
388 EmbeddingProvider::FeatureHash => {
389 TextEmbedding::new(FEATURE_HASH_EMBEDDING_MODEL, embed_text_local(text))
390 }
391 EmbeddingProvider::Off => Err(status::embedding_provider_off_error()),
392 EmbeddingProvider::OpenAi | EmbeddingProvider::Auto => {
393 bail!("cached embedding fallback must be local, feature-hash, or off")
394 }
395 }
396}
397
398fn embed_with_call_failure_fallback(
399 text: &str,
400 kind: LocalEmbeddingInputKind,
401 config: &EmbeddingConfig,
402 error: anyhow::Error,
403 cache: &mut EmbeddingFallbackCache,
404) -> Result<TextEmbedding> {
405 let Some(fallback) = config.fallback else {
406 return Err(error);
407 };
408 let fallback_runtime = status::provider_runtime(config, fallback);
409 if let Some(reason) = fallback_runtime.unavailable_reason {
410 bail!(
411 "embedding provider api failed: {error}; fallback {} unavailable: {reason}",
412 fallback.label()
413 );
414 }
415 let message = format!(
416 "configured embedding provider api failed: {}; using fallback {}",
417 error,
418 fallback.label()
419 );
420 crate::log::error("embedding", &message);
421 match fallback_runtime.provider {
422 EmbeddingProvider::Local => {
423 let embedding = local_semantic::embed_text(text, config, kind)?;
424 cache.call_failure_fallback = Some(fallback_runtime.provider);
425 cache.call_failure_fallback_target = Some(EmbeddingBackfillTarget {
426 model: embedding.model().to_string(),
427 dimensions: embedding.dimensions(),
428 });
429 Ok(embedding)
430 }
431 EmbeddingProvider::FeatureHash => {
432 cache.call_failure_fallback = Some(fallback_runtime.provider);
433 cache.call_failure_fallback_target = Some(EmbeddingBackfillTarget {
434 model: FEATURE_HASH_EMBEDDING_MODEL.to_string(),
435 dimensions: FEATURE_HASH_EMBEDDING_DIMENSIONS,
436 });
437 TextEmbedding::new(FEATURE_HASH_EMBEDDING_MODEL, embed_text_local(text))
438 }
439 EmbeddingProvider::Off => Err(status::embedding_provider_off_error_with_cause(format!(
440 "embedding provider api failed: {error}; fallback off disabled provider fallback"
441 ))),
442 EmbeddingProvider::OpenAi | EmbeddingProvider::Auto => Err(error),
443 }
444}
445
446fn memory_embedding_text(
447 title: &str,
448 content: &str,
449 memory_type: &str,
450 topic_key: Option<&str>,
451) -> String {
452 let mut text = String::new();
453 text.push_str(memory_type);
454 text.push('\n');
455 if let Some(topic_key) = topic_key {
456 text.push_str(topic_key);
457 text.push('\n');
458 }
459 text.push_str(title);
460 text.push('\n');
461 text.push_str(content);
462 text
463}
464
465#[derive(Debug, Clone, PartialEq, Eq)]
466enum ActiveEmbeddingProvider {
467 Local,
468 FeatureHash,
469 OpenAi { api_key: String },
470 Off,
471}
472
473fn active_provider(config: &EmbeddingConfig) -> Result<ActiveEmbeddingProvider> {
474 let status = status::resolve_provider_status(config);
475 if status.active_provider == EmbeddingProvider::Off.label() && status.degraded {
476 return Err(status::embedding_provider_off_error_with_cause(
477 status
478 .degradation_reason
479 .unwrap_or_else(|| "embedding provider fallback is off".to_string()),
480 ));
481 }
482 if let Some(reason) = status.unavailable_reason {
483 if status.active_provider == EmbeddingProvider::Off.label() {
484 return Err(status::embedding_provider_off_error_with_cause(reason));
485 }
486 if status.active_provider == EmbeddingProvider::Local.label() {
487 return Err(local_semantic::model_unavailable_error(reason));
488 }
489 bail!("{reason}");
490 }
491 match EmbeddingProvider::parse(&status.active_provider)? {
492 EmbeddingProvider::Local => Ok(ActiveEmbeddingProvider::Local),
493 EmbeddingProvider::FeatureHash => Ok(ActiveEmbeddingProvider::FeatureHash),
494 EmbeddingProvider::OpenAi => Ok(ActiveEmbeddingProvider::OpenAi {
495 api_key: configured_api_key(config)?.with_context(|| {
496 format!(
497 "embedding provider api requires {ENV_API_KEY} or {}",
498 config.api_key_env
499 )
500 })?,
501 }),
502 EmbeddingProvider::Off => Ok(ActiveEmbeddingProvider::Off),
503 EmbeddingProvider::Auto => bail!("auto must resolve to a concrete embedding provider"),
504 }
505}
506
507fn auto_api_key(config: &EmbeddingConfig) -> Result<Option<String>> {
508 if let Some(value) = env_value(ENV_API_KEY).or_else(|| env_value(ENV_API_KEY_LEGACY)) {
509 return Ok(Some(value));
510 }
511 if config.api_key_env != DEFAULT_API_KEY_ENV {
512 configured_api_key(config)
513 } else {
514 Ok(None)
515 }
516}
517
518fn configured_api_key(config: &EmbeddingConfig) -> Result<Option<String>> {
519 if let Some(value) = env_value(ENV_API_KEY).or_else(|| env_value(ENV_API_KEY_LEGACY)) {
520 return Ok(Some(value));
521 }
522 Ok(std::env::var(&config.api_key_env)
523 .ok()
524 .map(|value| value.trim().to_string())
525 .filter(|value| !value.is_empty()))
526}
527
528#[derive(Debug, Serialize)]
529struct OpenAiEmbeddingRequest<'a> {
530 input: &'a str,
531 model: &'a str,
532 encoding_format: &'static str,
533 #[serde(skip_serializing_if = "Option::is_none")]
534 dimensions: Option<usize>,
535}
536
537#[derive(Debug, Deserialize)]
538struct OpenAiEmbeddingResponse {
539 data: Vec<OpenAiEmbeddingData>,
540 model: Option<String>,
541}
542
543#[derive(Debug, Deserialize)]
544struct OpenAiEmbeddingData {
545 embedding: Vec<f32>,
546}
547
548fn embed_openai(text: &str, config: &EmbeddingConfig, api_key: &str) -> Result<TextEmbedding> {
549 if text.trim().is_empty() {
550 bail!("embedding input must not be empty");
551 }
552 let client = reqwest::blocking::Client::builder()
553 .timeout(Duration::from_secs(config.timeout_secs))
554 .build()
555 .context("build embedding HTTP client")?;
556 let request = OpenAiEmbeddingRequest {
557 input: text,
558 model: &config.model,
559 encoding_format: "float",
560 dimensions: config.dimensions,
561 };
562 let url = format!("{}/embeddings", config.base_url.trim_end_matches('/'));
563 let response = client
564 .post(&url)
565 .bearer_auth(api_key)
566 .json(&request)
567 .send()
568 .with_context(|| format!("call embedding provider at {url}"))?;
569 let status = response.status();
570 let body = response
571 .text()
572 .context("read embedding provider response body")?;
573 if !status.is_success() {
574 bail!(
575 "embedding provider returned HTTP {status}: {}",
576 truncate_error_body(&body)
577 );
578 }
579 parse_openai_embedding_response(&body, &config.model)
580}
581
582fn parse_openai_embedding_response(body: &str, fallback_model: &str) -> Result<TextEmbedding> {
583 let response: OpenAiEmbeddingResponse =
584 serde_json::from_str(body).context("parse embedding provider response")?;
585 let mut data = response.data.into_iter();
586 let first = data
587 .next()
588 .context("embedding provider response did not include data[0]")?;
589 if data.next().is_some() {
590 bail!("embedding provider returned multiple embeddings for single input");
591 }
592 TextEmbedding::new(
593 response.model.unwrap_or_else(|| fallback_model.to_string()),
594 first.embedding,
595 )
596}
597
598fn truncate_error_body(body: &str) -> String {
599 const MAX: usize = 500;
600 if body.len() <= MAX {
601 body.to_string()
602 } else {
603 let mut end = MAX;
604 while !body.is_char_boundary(end) {
605 end -= 1;
606 }
607 format!("{}...", &body[..end])
608 }
609}
610
611fn validate_embedding_values(values: &[f32]) -> Result<()> {
612 if values.is_empty() {
613 bail!("embedding vector must not be empty");
614 }
615 if values.iter().any(|value| !value.is_finite()) {
616 bail!("embedding vector contains non-finite values");
617 }
618 Ok(())
619}
620
621fn embed_text_local(text: &str) -> Vec<f32> {
622 let normalized = text.to_lowercase();
623 let mut vector = vec![0.0f32; LOCAL_EMBEDDING_DIMENSIONS];
624 for token in semantic_tokens(&normalized) {
625 add_feature(&mut vector, &format!("token:{token}"), 1.0);
626 }
627 for ngram in char_ngrams(&normalized) {
628 add_feature(&mut vector, &format!("ngram:{ngram}"), 0.35);
629 }
630 for (concept, phrases) in semantic_concepts() {
631 if phrases.iter().any(|phrase| normalized.contains(phrase)) {
632 add_feature(&mut vector, &format!("concept:{concept}"), 4.0);
633 }
634 }
635 normalize(&mut vector);
636 vector
637}
638
639fn semantic_tokens(text: &str) -> Vec<String> {
640 let mut tokens = Vec::new();
641 let mut current = String::new();
642 for ch in text.chars() {
643 if ch.is_alphanumeric() || is_cjk(ch) {
644 current.push(ch);
645 } else if !current.is_empty() {
646 tokens.push(std::mem::take(&mut current));
647 }
648 }
649 if !current.is_empty() {
650 tokens.push(current);
651 }
652 tokens
653}
654
655fn char_ngrams(text: &str) -> Vec<String> {
656 let chars: Vec<char> = text
657 .chars()
658 .filter(|ch| ch.is_alphanumeric() || is_cjk(*ch))
659 .collect();
660 let mut grams = Vec::new();
661 for width in [2usize, 3] {
662 if chars.len() < width {
663 continue;
664 }
665 grams.extend(
666 chars
667 .windows(width)
668 .map(|window| window.iter().collect::<String>()),
669 );
670 }
671 grams
672}
673
674fn add_feature(vector: &mut [f32], feature: &str, weight: f32) {
675 let digest = Sha256::digest(feature.as_bytes());
676 for offset in [0usize, 8, 16] {
677 let raw = u64::from_le_bytes([
678 digest[offset],
679 digest[offset + 1],
680 digest[offset + 2],
681 digest[offset + 3],
682 digest[offset + 4],
683 digest[offset + 5],
684 digest[offset + 6],
685 digest[offset + 7],
686 ]);
687 let idx = raw as usize % vector.len();
688 let sign = if raw & 1 == 0 { 1.0 } else { -1.0 };
689 vector[idx] += weight * sign;
690 }
691}
692
693fn normalize(vector: &mut [f32]) {
694 let norm = vector.iter().map(|value| value * value).sum::<f32>().sqrt();
695 if norm == 0.0 {
696 return;
697 }
698 for value in vector {
699 *value /= norm;
700 }
701}
702
703fn is_cjk(ch: char) -> bool {
704 matches!(
705 ch,
706 '\u{4E00}'..='\u{9FFF}' |
707 '\u{3400}'..='\u{4DBF}' |
708 '\u{F900}'..='\u{FAFF}'
709 )
710}
711
712fn semantic_concepts() -> &'static [(&'static str, &'static [&'static str])] {
713 &[
714 (
715 "data-security",
716 &[
717 "sqlcipher",
718 "encrypt",
719 "encrypted",
720 "encryption",
721 "secret",
722 "secrets",
723 "credential",
724 "credentials",
725 "private",
726 "confidential",
727 "protect",
728 "protected",
729 "at rest",
730 "persisted data",
731 "加密",
732 "密钥",
733 ],
734 ),
735 (
736 "transcript-capture",
737 &[
738 "transcript",
739 "raw archive",
740 "raw message",
741 "hook fallback",
742 "assistant message",
743 "conversation capture",
744 "jsonl",
745 "会话",
746 "原始消息",
747 ],
748 ),
749 (
750 "retrieval-quality",
751 &[
752 "semantic",
753 "embedding",
754 "vector",
755 "recall",
756 "search quality",
757 "paraphrase",
758 "检索",
759 "语义",
760 "召回",
761 "向量",
762 ],
763 ),
764 (
765 "current-state",
766 &[
767 "current decision",
768 "current state",
769 "supersede",
770 "supersedes",
771 "stale",
772 "replacement",
773 "现在",
774 "当前",
775 "替代",
776 ],
777 ),
778 (
779 "compression",
780 &[
781 "compress",
782 "compression",
783 "compaction",
784 "summarize",
785 "compressed",
786 "压缩",
787 "摘要",
788 "总结",
789 ],
790 ),
791 ]
792}
793
794#[cfg(test)]
795mod tests;