/// Forage Embedding Sidecar /// /// A lightweight HTTP server that accepts text and returns a float vector. /// The vector is suitable for ANN retrieval in tidalDB's HNSW index. /// /// Modes: /// --mock Deterministic pseudo-random unit vector derived from FNV-1a hash /// of the input text. No API key required. Useful for development /// and architecture testing; vectors are stable across runs but carry /// no semantic meaning. /// /// (default) OpenAI text-embedding-3-small via OPENAI_API_KEY env var. /// Requires a valid key. Produces genuine semantic vectors. /// /// Usage: /// forage-embedder --mock # dev / no key /// OPENAI_API_KEY=sk-... forage-embedder # production /// /// Protocol: /// POST /embed /// Body: { "text": "..." } /// Reply: { "vector": [f32, ...], "dim": 1536 } use std::net::SocketAddr; use std::sync::Arc; use axum::Json; use axum::Router; use axum::extract::State; use axum::http::StatusCode; use axum::response::IntoResponse; use axum::routing::post; use clap::Parser; use serde::{Deserialize, Serialize}; use tower_http::cors::CorsLayer; const DIM: usize = 1536; #[derive(Parser)] #[command( name = "forage-embedder", about = "Forage embedding sidecar (mock or OpenAI)" )] struct Args { /// Use deterministic mock embeddings (no API key required). #[arg(long)] mock: bool, /// Port to listen on. #[arg(long, default_value = "4243")] port: u16, } #[derive(Clone)] enum Mode { Mock, /// `client` is created once at startup and reused across requests. /// `reqwest::Client` is cheaply cloneable (`Arc`-backed connection pool). OpenAi { api_key: String, client: reqwest::Client, }, } #[derive(Deserialize)] struct EmbedReq { text: String, } #[derive(Serialize)] struct EmbedResp { vector: Vec, dim: usize, } async fn post_embed(State(mode): State>, Json(req): Json) -> impl IntoResponse { let vector = match mode.as_ref() { Mode::Mock => mock_embed(&req.text), Mode::OpenAi { api_key, client } => match openai_embed(client, api_key, &req.text).await { Ok(v) => v, Err(e) => { return ( StatusCode::BAD_GATEWAY, Json(serde_json::json!({ "error": e.to_string() })), ) .into_response(); } }, }; ( StatusCode::OK, Json(EmbedResp { dim: vector.len(), vector, }), ) .into_response() } /// Deterministic mock embedding: FNV-1a hash of text → seeded LCG → 1536-dim unit vector. /// /// Properties: /// - Same text → same vector (stable across runs) /// - Different texts → different vectors (hash dispersion) /// - No semantic meaning (random unit vectors) fn mock_embed(text: &str) -> Vec { // FNV-1a 64-bit hash as seed. let mut state: u64 = 14_695_981_039_346_656_037; for byte in text.bytes() { state ^= u64::from(byte); state = state.wrapping_mul(1_099_511_628_211); } let mut v = Vec::with_capacity(DIM); for _ in 0..DIM { // PCG-inspired step: fast, well-distributed. state = state .wrapping_mul(6_364_136_223_846_793_005) .wrapping_add(1_442_695_040_888_963_407); let sample = ((state >> 33) as f32 / u32::MAX as f32) * 2.0 - 1.0; v.push(sample); } l2_normalize(&mut v); v } /// OpenAI text-embedding-3-small call. async fn openai_embed( client: &reqwest::Client, api_key: &str, text: &str, ) -> Result, String> { let resp = client .post("https://api.openai.com/v1/embeddings") .bearer_auth(api_key) .json(&serde_json::json!({ "model": "text-embedding-3-small", "input": text })) .send() .await .map_err(|e| format!("request failed: {e}"))?; if !resp.status().is_success() { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); return Err(format!("OpenAI error {status}: {body}")); } let json: serde_json::Value = resp.json().await.map_err(|e| format!("parse error: {e}"))?; let embedding = json["data"][0]["embedding"] .as_array() .ok_or("missing embedding field")? .iter() .map(|v| v.as_f64().unwrap_or(0.0) as f32) .collect::>(); Ok(embedding) } fn l2_normalize(v: &mut [f32]) { let norm: f32 = v.iter().map(|x| x * x).sum::().sqrt(); if norm > 1e-9 { for x in v.iter_mut() { *x /= norm; } } } #[tokio::main] async fn main() { let args = Args::parse(); let mode = if args.mock { println!("forage-embedder: mock mode (deterministic, no API key)"); Mode::Mock } else { let key = std::env::var("OPENAI_API_KEY").unwrap_or_else(|_| { eprintln!( "forage-embedder: OPENAI_API_KEY not set; falling back to mock mode.\n\ Set OPENAI_API_KEY or pass --mock to suppress this warning." ); String::new() }); if key.is_empty() { Mode::Mock } else { println!("forage-embedder: OpenAI mode (text-embedding-3-small)"); Mode::OpenAi { api_key: key, client: reqwest::Client::new(), } } }; let state = Arc::new(mode); let app = Router::new() .route("/embed", post(post_embed)) .layer(CorsLayer::permissive()) .with_state(state); let addr = SocketAddr::from(([127, 0, 0, 1], args.port)); println!("forage-embedder listening on http://{addr}"); let listener = tokio::net::TcpListener::bind(addr) .await .expect("failed to bind"); axum::serve(listener, app).await.expect("server error"); }