From fe0587e90feccb75afe465c39b454cf476fa44f2 Mon Sep 17 00:00:00 2001 From: harehare Date: Thu, 22 Oct 2026 21:47:32 +0900 Subject: [PATCH 1/2] perf(sql): keep the last two parsed vectors for similarity functions The query vector is the same for every row, so it is parsed once per query instead of once per row. --- src/sql/scalar.rs | 43 ++++++++++++++++++++++++++++++++++--------- 1 file changed, 34 insertions(+), 9 deletions(-) diff --git a/src/sql/scalar.rs b/src/sql/scalar.rs index 8a19324..3c5fcf8 100644 --- a/src/sql/scalar.rs +++ b/src/sql/scalar.rs @@ -38,12 +38,12 @@ pub(super) fn eval_scalar_function(name: &str, args: &[Value]) -> Value { if a.len() != b.len() { return Value::Null; } - let dot: f64 = a.iter().zip(&b).map(|(x, y)| x * y).sum(); + let dot: f64 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum(); match name.to_lowercase().as_str() { "dot_product" => Value::Float(dot), "l2_distance" => Value::Float( a.iter() - .zip(&b) + .zip(b.iter()) .map(|(x, y)| (x - y).powi(2)) .sum::() .sqrt(), @@ -568,15 +568,40 @@ fn eval_mq_scalar(program: &str, content: &str) -> Value { } } +type CachedVector = (String, std::rc::Rc<[f64]>); + +thread_local! { + /// The two most recently parsed vectors, most recent first. A similarity + /// query passes the same query vector for every row, so keeping it + /// alongside the current row's vector avoids re-parsing it each time. + static RECENT_VECTORS: std::cell::RefCell> = + const { std::cell::RefCell::new(Vec::new()) }; +} + /// Parses a non-empty JSON array of numbers; `None` for anything else. -fn parse_vector(value: &Value) -> Option> { - let serde_json::Value::Array(items) = serde_json::from_str(value.as_str()?).ok()? else { - return None; - }; - if items.is_empty() { - return None; +fn parse_vector(value: &Value) -> Option> { + let text = value.as_str()?; + let hit = RECENT_VECTORS.with(|c| { + let mut recent = c.borrow_mut(); + let pos = recent.iter().position(|(t, _)| t == text)?; + recent[..=pos].rotate_right(1); + Some(recent[0].1.clone()) + }); + if hit.is_some() { + return hit; } - items.iter().map(serde_json::Value::as_f64).collect() + let inner = text.trim().strip_prefix('[')?.strip_suffix(']')?; + let parsed: Option> = inner + .split(',') + .map(|n| n.trim().parse::().ok().filter(|f| f.is_finite())) + .collect(); + let vector: std::rc::Rc<[f64]> = parsed?.into(); + RECENT_VECTORS.with(|c| { + let mut recent = c.borrow_mut(); + recent.insert(0, (text.to_string(), vector.clone())); + recent.truncate(2); + }); + Some(vector) } /// Looks up `path` (`key`, `$.a.b`, `a[0].b`) in `json`. From 06e6fdcad40125ad631f82effab399d1b98c9f12 Mon Sep 17 00:00:00 2001 From: harehare Date: Thu, 22 Oct 2026 22:05:18 +0900 Subject: [PATCH 2/2] fix(sql): validate vectors with serde_json before caching The comma-separated parser used f64::parse, which accepts spellings such as `+1`, `02`, `.5` and `1.` that JSON rejects, so malformed embeddings produced similarity scores instead of NULL. --- src/sql/scalar.rs | 11 +++++------ src/sql/tests.rs | 3 +++ 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/sql/scalar.rs b/src/sql/scalar.rs index 3c5fcf8..3c3644d 100644 --- a/src/sql/scalar.rs +++ b/src/sql/scalar.rs @@ -590,12 +590,11 @@ fn parse_vector(value: &Value) -> Option> { if hit.is_some() { return hit; } - let inner = text.trim().strip_prefix('[')?.strip_suffix(']')?; - let parsed: Option> = inner - .split(',') - .map(|n| n.trim().parse::().ok().filter(|f| f.is_finite())) - .collect(); - let vector: std::rc::Rc<[f64]> = parsed?.into(); + let parsed: Vec = serde_json::from_str(text).ok()?; + if parsed.is_empty() || !parsed.iter().all(|f| f.is_finite()) { + return None; + } + let vector: std::rc::Rc<[f64]> = parsed.into(); RECENT_VECTORS.with(|c| { let mut recent = c.borrow_mut(); recent.insert(0, (text.to_string(), vector.clone())); diff --git a/src/sql/tests.rs b/src/sql/tests.rs index 933293e..c5c7589 100644 --- a/src/sql/tests.rs +++ b/src/sql/tests.rs @@ -3217,6 +3217,9 @@ fn test_sql_correlated_subquery_expression_forms(#[case] cond: &str, #[case] exp #[case("cosine_similarity('[]', '[]')", "NULL")] #[case("cosine_similarity('[1,\"a\"]', '[1,0]')", "NULL")] #[case("cosine_similarity('not json', '[1,0]')", "NULL")] +#[case("dot_product('[+1,02]', '[3,4]')", "NULL")] +#[case("dot_product('[.5,1.]', '[3,4]')", "NULL")] +#[case("dot_product('[1,2,]', '[3,4]')", "NULL")] #[case("cosine_similarity(NULL, '[1,0]')", "NULL")] fn test_sql_vector_functions(#[case] expr: &str, #[case] expected: &str) { let store = DocumentStore::new();