Skip to main content

remem/retrieval/
embedding.rs

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