From 725d99b99aa2c890850c7306af0ed924b1b4d320 Mon Sep 17 00:00:00 2001 From: Aditya kumar singh <143548997+Adityakk9031@users.noreply.github.com> Date: Thu, 22 Oct 2026 11:40:02 +0530 Subject: [PATCH 1/5] feat(rag): wire vector_store, BM25, and RRF params into retrieve backend (#913) --- core/src/features/rag/bm25_index.cpp | 14 +- core/src/features/rag/bm25_index.h | 10 +- core/src/features/rag/rac_rag_proto_abi.cpp | 93 ++++++++++++- core/src/features/rag/rag_backend.cpp | 24 ++++ core/src/features/rag/rag_backend.h | 10 ++ core/src/features/rag/rag_fusion.cpp | 29 ++-- core/src/features/rag/rag_fusion.h | 6 +- core/src/features/rag/rag_pipeline_graph.cpp | 4 +- core/src/features/rag/rag_pipeline_graph.h | 5 + .../solutions/operators/op_engine_backed.cpp | 114 +++++++++++++++- core/src/solutions/solution_converter.cpp | 18 ++- .../test_advanced_modality_proto_abi.cpp | 120 +++++++++++++++++ core/tests/test_rag_fusion.cpp | 32 +++++ core/tests/test_solution_runner.cpp | 124 +++++++++++++++++- idl/SCHEMA_LOCK | 4 +- idl/VERSION | 2 +- idl/rag.proto | 22 ++++ idl/solutions.proto | 7 +- 18 files changed, 593 insertions(+), 45 deletions(-) diff --git a/core/src/features/rag/bm25_index.cpp b/core/src/features/rag/bm25_index.cpp index 98ffb4df64..ca984c0cfd 100644 --- a/core/src/features/rag/bm25_index.cpp +++ b/core/src/features/rag/bm25_index.cpp @@ -194,12 +194,16 @@ size_t BM25Index::size() const { // ============================================================================= std::vector> BM25Index::search(const std::string& query, - size_t top_k) const { + size_t top_k, float k1, + float b) const { std::lock_guard lock(mutex_); if (total_chunks_ == 0) return {}; + const double effective_k1 = k1 > 0.0f ? static_cast(k1) : static_cast(kDefaultK1_); + const double effective_b = b >= 0.0f ? static_cast(b) : static_cast(kDefaultB_); + auto query_tokens = tokenize(query); if (query_tokens.empty()) return {}; @@ -251,10 +255,10 @@ std::vector> BM25Index::search(const std::string& double tf = static_cast(term_it->second); // BM25 term score - double numerator = tf * (static_cast(k1_) + 1.0); - double denominator = tf + static_cast(k1_) * - (1.0 - static_cast(b_) + - static_cast(b_) * doc_len / avg_chunk_length_); + double numerator = tf * (effective_k1 + 1.0); + double denominator = tf + effective_k1 * + (1.0 - effective_b + + effective_b * doc_len / avg_chunk_length_); score += idf * (numerator / denominator); } diff --git a/core/src/features/rag/bm25_index.h b/core/src/features/rag/bm25_index.h index c88fed60eb..0ed1d6b17e 100644 --- a/core/src/features/rag/bm25_index.h +++ b/core/src/features/rag/bm25_index.h @@ -26,12 +26,14 @@ class BM25Index { void clear(); size_t size() const; - /// Returns (chunk_id, bm25_score) sorted descending by score - std::vector> search(const std::string& query, size_t top_k) const; + /// Returns (chunk_id, bm25_score) sorted descending by score. + /// k1 and b parameterize term frequency saturation and document length normalization. + std::vector> search(const std::string& query, size_t top_k, + float k1 = 1.2f, float b = 0.75f) const; private: - static constexpr float k1_ = 1.2f; - static constexpr float b_ = 0.75f; + static constexpr float kDefaultK1_ = 1.2f; + static constexpr float kDefaultB_ = 0.75f; std::vector tokenize(const std::string& text) const; diff --git a/core/src/features/rag/rac_rag_proto_abi.cpp b/core/src/features/rag/rac_rag_proto_abi.cpp index d58454b785..736df9ee44 100644 --- a/core/src/features/rag/rac_rag_proto_abi.cpp +++ b/core/src/features/rag/rac_rag_proto_abi.cpp @@ -320,13 +320,66 @@ RAGBackendConfig build_backend_config(const runanywhere::v1::RAGConfiguration& p bc.prompt_template = proto.prompt_template(); bc.rerank = proto.rerank_results(); bc.embedding_model_id = proto.embedding_model_id(); + if (proto.has_bm25_k1() && proto.bm25_k1() > 0.0f) + bc.bm25_k1 = proto.bm25_k1(); + if (proto.has_bm25_b() && proto.bm25_b() >= 0.0f) + bc.bm25_b = proto.bm25_b(); + if (proto.has_rrf_k() && proto.rrf_k() > 0) + bc.rrf_k = proto.rrf_k(); return bc; } +// Rejects non-finite or out-of-range BM25 / RRF tuning values at the ABI boundary: +// b outside [0, 1] or an infinite k1 makes every BM25 score drop out of the +// `score > 0` filter (negative or NaN), which would silently return no hits. +bool validate_bm25_rrf_params(bool has_k1, float k1, bool has_b, float b, bool has_rrf_k, + int32_t rrf_k, const char* owner, std::string* out_message) { + const auto fail = [&](const char* detail) { + if (out_message) + *out_message = std::string(owner) + "." + detail; + return false; + }; + if (has_k1 && (!std::isfinite(k1) || k1 < 0.0f)) + return fail("bm25_k1 must be a finite value >= 0"); + if (has_b && (!std::isfinite(b) || b < 0.0f || b > 1.0f)) + return fail("bm25_b must be a finite value in 0.0...1.0"); + if (has_rrf_k && rrf_k < 0) + return fail("rrf_k must be >= 0"); + return true; +} + +// Only the in-process USearch store exists on-device. PGVECTOR is a known server-only store +// (NOT_SUPPORTED); any other value -- proto3 keeps unknown enum numbers -- is rejected as an +// invalid argument instead of silently running against USearch. +rac_result_t check_vector_store(bool has_vector_store, runanywhere::v1::VectorStore store, + std::string* out_message) { + if (!has_vector_store || store == runanywhere::v1::VECTOR_STORE_UNSPECIFIED || + store == runanywhere::v1::VECTOR_STORE_USEARCH) { + return RAC_SUCCESS; + } + if (store == runanywhere::v1::VECTOR_STORE_PGVECTOR) { + if (out_message) { + *out_message = "VECTOR_STORE_PGVECTOR is not supported on-device; only " + "VECTOR_STORE_USEARCH is supported"; + } + return RAC_ERROR_NOT_SUPPORTED; + } + if (out_message) { + *out_message = "unknown vector_store value " + std::to_string(static_cast(store)) + + "; only VECTOR_STORE_USEARCH is supported"; + } + return RAC_ERROR_INVALID_ARGUMENT; +} + bool validate_rag_configuration(const runanywhere::v1::RAGConfiguration& proto, std::string* out_message) { const RAGBackendConfig defaults; + if (check_vector_store(proto.has_vector_store(), proto.vector_store(), out_message) != + RAC_SUCCESS) { + return false; + } + if (proto.has_embedding_dimension() && proto.embedding_dimension() < 1) { if (out_message) *out_message = "RAGConfiguration.embedding_dimension must be >= 1 when set"; @@ -374,7 +427,9 @@ bool validate_rag_configuration(const runanywhere::v1::RAGConfiguration& proto, return false; } - return true; + return validate_bm25_rrf_params(proto.has_bm25_k1(), proto.bm25_k1(), proto.has_bm25_b(), + proto.bm25_b(), proto.has_rrf_k(), proto.rrf_k(), + "RAGConfiguration", out_message); } runanywhere::v1::RAGStatistics make_stats(RAGBackend& backend) { @@ -488,6 +543,24 @@ rac_result_t execute_rag_query(const std::shared_ptr& s, if (retrieval.has_scope_prefix()) overrides.scope_prefix = retrieval.scope_prefix(); + if (const rac_result_t store_rc = + check_vector_store(retrieval.has_vector_store(), retrieval.vector_store(), out_error); + store_rc != RAC_SUCCESS) { + return store_rc; + } + if (!validate_bm25_rrf_params(retrieval.has_bm25_k1(), retrieval.bm25_k1(), + retrieval.has_bm25_b(), retrieval.bm25_b(), + retrieval.has_rrf_k(), retrieval.rrf_k(), + "RAGRetrievalOptions", out_error)) { + return RAC_ERROR_INVALID_ARGUMENT; + } + if (retrieval.has_bm25_k1()) + overrides.bm25_k1 = retrieval.bm25_k1(); + if (retrieval.has_bm25_b()) + overrides.bm25_b = retrieval.bm25_b(); + if (retrieval.has_rrf_k()) + overrides.rrf_k = retrieval.rrf_k(); + publish_capability(runanywhere::v1::CAPABILITY_OPERATION_EVENT_KIND_RAG_QUERY_STARTED, "rag.query", 0.0f, 1, 0, nullptr, 0.0, s->llm_model_id.empty() ? s->embedding_model_id.c_str() @@ -674,6 +747,24 @@ rac_result_t execute_rag_search(const std::shared_ptr& s, if (retrieval.has_scope_prefix()) overrides.scope_prefix = retrieval.scope_prefix(); + if (const rac_result_t store_rc = + check_vector_store(retrieval.has_vector_store(), retrieval.vector_store(), out_error); + store_rc != RAC_SUCCESS) { + return store_rc; + } + if (!validate_bm25_rrf_params(retrieval.has_bm25_k1(), retrieval.bm25_k1(), + retrieval.has_bm25_b(), retrieval.bm25_b(), + retrieval.has_rrf_k(), retrieval.rrf_k(), + "RAGRetrievalOptions", out_error)) { + return RAC_ERROR_INVALID_ARGUMENT; + } + if (retrieval.has_bm25_k1()) + overrides.bm25_k1 = retrieval.bm25_k1(); + if (retrieval.has_bm25_b()) + overrides.bm25_b = retrieval.bm25_b(); + if (retrieval.has_rrf_k()) + overrides.rrf_k = retrieval.rrf_k(); + publish_capability(runanywhere::v1::CAPABILITY_OPERATION_EVENT_KIND_RAG_QUERY_STARTED, "rag.search", 0.0f, 1, 0, nullptr, 0.0, s->embedding_model_id.c_str()); diff --git a/core/src/features/rag/rag_backend.cpp b/core/src/features/rag/rag_backend.cpp index 49a6b028e1..8182122072 100644 --- a/core/src/features/rag/rag_backend.cpp +++ b/core/src/features/rag/rag_backend.cpp @@ -578,6 +578,9 @@ rac_result_t RAGBackend::query(const std::string& question, const rac_llm_option g_in.max_context_tokens = config_.max_context_tokens; g_in.prompt_template = config_.prompt_template; g_in.rerank = config_.rerank; + g_in.bm25_k1 = config_.bm25_k1; + g_in.bm25_b = config_.bm25_b; + g_in.rrf_k = config_.rrf_k; } // Per-query overrides from RAGQueryOptions (idl/rag.proto). Zero/unset @@ -596,6 +599,15 @@ rac_result_t RAGBackend::query(const std::string& question, const rac_llm_option g_in.multi_query_count = static_cast(overrides->multi_query_count); } g_in.scope_prefix = overrides->scope_prefix; + if (overrides->bm25_k1 > 0.0f) { + g_in.bm25_k1 = overrides->bm25_k1; + } + if (overrides->bm25_b >= 0.0f) { + g_in.bm25_b = overrides->bm25_b; + } + if (overrides->rrf_k > 0) { + g_in.rrf_k = overrides->rrf_k; + } } g_in.question = question; @@ -703,6 +715,9 @@ rac_result_t RAGBackend::retrieve(const std::string& question, g_in.max_context_tokens = config_.max_context_tokens; g_in.prompt_template = config_.prompt_template; g_in.rerank = config_.rerank; + g_in.bm25_k1 = config_.bm25_k1; + g_in.bm25_b = config_.bm25_b; + g_in.rrf_k = config_.rrf_k; wants_llm_features = config_.rerank; } @@ -718,6 +733,15 @@ rac_result_t RAGBackend::retrieve(const std::string& question, g_in.multi_query_count = static_cast(overrides->multi_query_count); } g_in.scope_prefix = overrides->scope_prefix; + if (overrides->bm25_k1 > 0.0f) { + g_in.bm25_k1 = overrides->bm25_k1; + } + if (overrides->bm25_b >= 0.0f) { + g_in.bm25_b = overrides->bm25_b; + } + if (overrides->rrf_k > 0) { + g_in.rrf_k = overrides->rrf_k; + } wants_llm_features = wants_llm_features || overrides->enable_multi_query; } diff --git a/core/src/features/rag/rag_backend.h b/core/src/features/rag/rag_backend.h index 778385cf40..524cae531e 100644 --- a/core/src/features/rag/rag_backend.h +++ b/core/src/features/rag/rag_backend.h @@ -57,6 +57,10 @@ struct RAGBackendConfig { // relevance scoring before context assembly (RAGConfiguration.rerank_results). bool rerank = false; + float bm25_k1 = 1.2f; + float bm25_b = 0.75f; + int32_t rrf_k = 60; + std::string embedding_model_id; }; @@ -128,6 +132,12 @@ class RAGBackend { // Scoped retrieval: only chunks whose document_id starts with this // prefix are eligible. Empty = whole index. std::string scope_prefix; + // BM25 / RRF retrieval tuning overrides. Each field has its own "unset" sentinel + // meaning "use the session config": bm25_k1 <= 0, bm25_b < 0 (so b = 0, no length + // normalization, is a real override), rrf_k <= 0. + float bm25_k1 = 0.0f; + float bm25_b = -1.0f; + int32_t rrf_k = 0; }; rac_result_t query(const std::string& question, const rac_llm_options_t* options, diff --git a/core/src/features/rag/rag_fusion.cpp b/core/src/features/rag/rag_fusion.cpp index d2eedea3f9..e2062bb587 100644 --- a/core/src/features/rag/rag_fusion.cpp +++ b/core/src/features/rag/rag_fusion.cpp @@ -13,35 +13,40 @@ namespace runanywhere { namespace rag { std::vector fuse_rankings(const std::vector>& rankings, - const VectorStoreUSearch* vector_store, size_t top_k) { - static constexpr float kRRFConstant = 60.0f; + const VectorStoreUSearch* vector_store, size_t top_k, + int32_t rrf_k) { + // Accumulate in double: with a large rrf_k (up to INT32_MAX) adjacent float + // denominators round to the same value and would tie unrelated candidates. + const double kRRFConstant = rrf_k > 0 ? static_cast(rrf_k) : 60.0; - std::unordered_map rrf_scores; + std::unordered_map rrf_scores; for (const auto& ranking : rankings) { for (size_t i = 0; i < ranking.size(); ++i) { - rrf_scores[ranking[i]] += 1.0f / (kRRFConstant + static_cast(i + 1)); + rrf_scores[ranking[i]] += 1.0 / (kRRFConstant + static_cast(i + 1)); } } - std::vector> sorted_ids(rrf_scores.begin(), rrf_scores.end()); - std::sort(sorted_ids.begin(), sorted_ids.end(), - [](const auto& a, const auto& b) { return a.second > b.second; }); + std::vector> sorted_ids(rrf_scores.begin(), rrf_scores.end()); + // Ties break on id so the order never depends on unordered_map iteration. + std::sort(sorted_ids.begin(), sorted_ids.end(), [](const auto& a, const auto& b) { + return a.second != b.second ? a.second > b.second : a.first < b.first; + }); if (sorted_ids.size() > top_k) sorted_ids.resize(top_k); // Best achievable score: rank-1 in every ranking. Normalizes into [0,1] so // the reported similarity is comparable regardless of fan-out. - const float max_possible = - rankings.empty() ? 1.0f : static_cast(rankings.size()) / (kRRFConstant + 1.0f); + const double max_possible = + rankings.empty() ? 1.0 : static_cast(rankings.size()) / (kRRFConstant + 1.0); std::vector fused; fused.reserve(sorted_ids.size()); for (const auto& [id, rrf_score] : sorted_ids) { - float normalized = max_possible > 0.0f ? rrf_score / max_possible : rrf_score; - normalized = std::min(1.0f, std::max(0.0f, normalized)); + double normalized = max_possible > 0.0 ? rrf_score / max_possible : rrf_score; + normalized = std::min(1.0, std::max(0.0, normalized)); SearchResult result; result.id = id; - result.score = normalized; + result.score = static_cast(normalized); if (vector_store) { auto chunk = vector_store->get_chunk(id); if (chunk) { diff --git a/core/src/features/rag/rag_fusion.h b/core/src/features/rag/rag_fusion.h index e27355dc11..b0ca9507e5 100644 --- a/core/src/features/rag/rag_fusion.h +++ b/core/src/features/rag/rag_fusion.h @@ -22,12 +22,14 @@ namespace rag { * @brief Reciprocal Rank Fusion over an arbitrary set of ranked id lists * (dense + BM25 for the original query and every multi-query variant). * - * Score for an id = sum over rankings of 1/(k + rank), k=60, rank 1-based. + * Score for an id = sum over rankings of 1/(k + rank), rank 1-based, k = `rrf_k` + * (the RAG config / per-query override); a non-positive `rrf_k` falls back to 60. * Results are sorted by score, truncated to top_k, normalized into [0,1], and * materialized (text + metadata) from `vector_store` when non-null. */ std::vector fuse_rankings(const std::vector>& rankings, - const VectorStoreUSearch* vector_store, size_t top_k); + const VectorStoreUSearch* vector_store, size_t top_k, + int32_t rrf_k = 60); /** * @brief Parse an LLM query-rewrite response into up to `count` variants. diff --git a/core/src/features/rag/rag_pipeline_graph.cpp b/core/src/features/rag/rag_pipeline_graph.cpp index b2e2eddb5c..43b09e8d70 100644 --- a/core/src/features/rag/rag_pipeline_graph.cpp +++ b/core/src/features/rag/rag_pipeline_graph.cpp @@ -276,7 +276,7 @@ rac_result_t run_rag_retrieve(const RAGGraphInputs& inputs, RAGGraphResult& out_ rankings.push_back(std::move(dense_ids)); if (bm25) { - const auto bm = bm25->search(q, fetch_k); + const auto bm = bm25->search(q, fetch_k, inputs.bm25_k1, inputs.bm25_b); std::vector bm_ids; for (const auto& [id, score] : bm) { (void)score; @@ -289,7 +289,7 @@ rac_result_t run_rag_retrieve(const RAGGraphInputs& inputs, RAGGraphResult& out_ } (void)embedded_any; - results = fuse_rankings(rankings, vstore, top_k); + results = fuse_rankings(rankings, vstore, top_k, inputs.rrf_k); LOGI("RAG retrieve: %zu queries, %zu rankings, %zu fused%s", queries.size(), rankings.size(), results.size(), scoped ? " (scoped)" : ""); diff --git a/core/src/features/rag/rag_pipeline_graph.h b/core/src/features/rag/rag_pipeline_graph.h index e5e2ea6e58..9a8d5afd38 100644 --- a/core/src/features/rag/rag_pipeline_graph.h +++ b/core/src/features/rag/rag_pipeline_graph.h @@ -84,6 +84,11 @@ struct RAGGraphInputs { // are eligible. Empty = whole index. std::string scope_prefix; + // BM25 and RRF retrieval tuning parameters. + float bm25_k1 = 1.2f; + float bm25_b = 0.75f; + int32_t rrf_k = 60; + // Borrowed from RAGBackend. Checked between phases and by the streaming // token callback in addition to the provider's native cancel hook. const std::atomic* cancel_requested = nullptr; diff --git a/core/src/solutions/operators/op_engine_backed.cpp b/core/src/solutions/operators/op_engine_backed.cpp index b003e8f9b7..794d6b636f 100644 --- a/core/src/solutions/operators/op_engine_backed.cpp +++ b/core/src/solutions/operators/op_engine_backed.cpp @@ -37,9 +37,12 @@ // param is missing or unparseable the operator fails honestly on the // first input and the runner cancels the graph. +#include #include #include +#include #include +#include #include #include #include @@ -116,6 +119,53 @@ float param_float_or(const runanywhere::v1::OperatorSpec& spec, const std::strin } } +// Strict variants: a key that is present and non-empty must parse in full +// ("30junk" and "0,8" are rejected) and, for floats, be finite. Returns false +// on malformed input so the caller can fail honestly instead of silently +// substituting a value; an absent or empty key writes *out = fallback. +bool param_int_strict(const runanywhere::v1::OperatorSpec& spec, const std::string& key, + int fallback, int* out) { + *out = fallback; + const std::string* value = find_param(spec, key); + if (!value || value->empty()) + return true; + try { + size_t pos = 0; + const int parsed = std::stoi(*value, &pos); + if (pos != value->size()) + return false; + *out = parsed; + return true; + } catch (...) { + return false; + } +} + +bool param_float_strict(const runanywhere::v1::OperatorSpec& spec, const std::string& key, + float fallback, float* out) { + *out = fallback; + const std::string* value = find_param(spec, key); + if (!value || value->empty()) + return true; + // Parse with the classic "C" locale: std::stof follows the process locale, so a + // host that set a comma-decimal locale would reject "0.8" and accept "0,8". + std::istringstream stream(*value); + stream.imbue(std::locale::classic()); + float parsed = 0.0f; + if (!(stream >> parsed) || stream.peek() != std::char_traits::eof() || + !std::isfinite(parsed)) { + return false; + } + *out = parsed; + return true; +} + +// True when `key` is present with a non-empty value. +bool has_param(const runanywhere::v1::OperatorSpec& spec, const std::string& key) { + const std::string* value = find_param(spec, key); + return value != nullptr && !value->empty(); +} + // Parse an unsigned 64-bit identifier (e.g. a host-provided session handle) // from `OperatorSpec.params`. Returns true and writes *out on success; // returns false when the key is missing, empty, or unparseable so the @@ -739,16 +789,55 @@ class RetrieveNode final : public OperatorNode { RetrieveNode(std::string name, const runanywhere::v1::OperatorSpec& spec) : PipelineNode(std::move(name), /*input*/ 8, /*output*/ 8, OverflowPolicy::BlockProducer), model_id_(spec.model_id()), - retrieval_top_k_(param_int_or(spec, "k", 0)), - similarity_threshold_(param_float_or(spec, "similarity_threshold", 0.0f)), has_session_handle_(param_uint64(spec, "session_handle_id", &session_handle_id_)) { + const bool numeric_ok = param_int_strict(spec, "k", 0, &retrieval_top_k_) && + param_float_strict(spec, "similarity_threshold", 0.0f, + &similarity_threshold_) && + param_float_strict(spec, "bm25_k1", 0.0f, &bm25_k1_) && + param_float_strict(spec, "bm25_b", -1.0f, &bm25_b_) && + param_int_strict(spec, "rrf_k", 0, &rrf_k_); + if (!numeric_ok) { + param_error_ = + "retrieve params 'k', 'similarity_threshold', 'bm25_k1', 'bm25_b' and 'rrf_k' " + "must be fully-numeric finite values"; + } else if (has_param(spec, "k") && retrieval_top_k_ < 1) { + param_error_ = "retrieve param 'k' must be >= 1"; + } else if (has_param(spec, "similarity_threshold") && + (similarity_threshold_ < 0.0f || similarity_threshold_ > 1.0f)) { + param_error_ = "retrieve param 'similarity_threshold' must be in 0.0...1.0"; + } else if (bm25_k1_ < 0.0f) { + param_error_ = "retrieve param 'bm25_k1' must be >= 0"; + } else if (has_param(spec, "bm25_b") && (bm25_b_ < 0.0f || bm25_b_ > 1.0f)) { + param_error_ = "retrieve param 'bm25_b' must be in 0.0...1.0"; + } else if (rrf_k_ < 0) { + param_error_ = "retrieve param 'rrf_k' must be >= 0"; + } if (const std::string* p = find_param(spec, "system_prompt"); p) { system_prompt_ = *p; } + if (const std::string* vs = find_param(spec, "vector_store"); vs) { + vector_store_ = *vs; + } } protected: void process(Item item, OutputEdge& out) override { + if (!param_error_.empty()) { + set_error_detail(name(), param_error_); + cancel_graph(this->cancel_token()); + return; + } + if (!vector_store_.empty() && vector_store_ != "VECTOR_STORE_USEARCH" && + vector_store_ != "usearch") { + const bool pgvector = + vector_store_ == "VECTOR_STORE_PGVECTOR" || vector_store_ == "pgvector"; + set_error_detail(name(), pgvector ? "VECTOR_STORE_PGVECTOR is not supported " + "on-device; only VECTOR_STORE_USEARCH is supported" + : "unknown vector_store '" + vector_store_ + + "'; only VECTOR_STORE_USEARCH is supported"); + cancel_graph(this->cancel_token()); + return; + } if (!has_session_handle_) { set_error_detail(name(), "retrieve requires OperatorSpec.params['session_handle_id'] " @@ -785,6 +874,18 @@ class RetrieveNode final : public OperatorNode { if (similarity_threshold_ > 0.0f) { request.mutable_retrieval()->set_score_threshold(similarity_threshold_); } + if (bm25_k1_ > 0.0f) { + request.mutable_retrieval()->set_bm25_k1(bm25_k1_); + } + if (bm25_b_ >= 0.0f) { + request.mutable_retrieval()->set_bm25_b(bm25_b_); + } + if (rrf_k_ > 0) { + request.mutable_retrieval()->set_rrf_k(rrf_k_); + } + if (!vector_store_.empty()) { + request.mutable_retrieval()->set_vector_store(runanywhere::v1::VECTOR_STORE_USEARCH); + } // Per the RAG service contract, retrieve does not generate. Setting // max_tokens to 0 still produces a RAGResult with retrieved_chunks // populated; downstream context_build operators consume those @@ -858,8 +959,13 @@ class RetrieveNode final : public OperatorNode { private: std::string model_id_; - int retrieval_top_k_; - float similarity_threshold_; + int retrieval_top_k_{0}; + float similarity_threshold_{0.0f}; + float bm25_k1_{0.0f}; + float bm25_b_{-1.0f}; + int rrf_k_{0}; + std::string vector_store_; + std::string param_error_; std::uint64_t session_handle_id_{0}; bool has_session_handle_{false}; std::string system_prompt_; diff --git a/core/src/solutions/solution_converter.cpp b/core/src/solutions/solution_converter.cpp index 6f859c273b..b2404fe3fd 100644 --- a/core/src/solutions/solution_converter.cpp +++ b/core/src/solutions/solution_converter.cpp @@ -7,6 +7,10 @@ #include "pipeline.pb.h" #include "solutions.pb.h" +#include +#include +#include +#include #include #include "rac/core/rac_error.h" @@ -35,6 +39,16 @@ void add_edge(PipelineSpec* spec, const std::string& from, const std::string& to e->set_to(to); } +// Operator params are strings. Format floats with the classic "C" locale and enough digits to +// round-trip, matching the retrieve operator's locale-independent parser; std::to_string follows +// the process locale (a comma-decimal host would emit "1,500000") and keeps only six decimals. +std::string format_float_param(float value) { + std::ostringstream stream; + stream.imbue(std::locale::classic()); + stream << std::setprecision(std::numeric_limits::max_digits10) << value; + return stream.str(); +} + // --------------------------------------------------------------------------- // These expansions intentionally reference engine-backed operator type names // without installing factories for them. PipelineExecutor must fail with @@ -144,10 +158,10 @@ void expand_rag(const runanywhere::v1::RAGConfig& cfg, PipelineSpec* out) { runanywhere::v1::VectorStore_Name(cfg.vector_store()); } if (cfg.bm25_k1() > 0.0f) { - (*retrieve->mutable_params())["bm25_k1"] = std::to_string(cfg.bm25_k1()); + (*retrieve->mutable_params())["bm25_k1"] = format_float_param(cfg.bm25_k1()); } if (cfg.bm25_b() > 0.0f) { - (*retrieve->mutable_params())["bm25_b"] = std::to_string(cfg.bm25_b()); + (*retrieve->mutable_params())["bm25_b"] = format_float_param(cfg.bm25_b()); } if (cfg.rrf_k() > 0) { (*retrieve->mutable_params())["rrf_k"] = std::to_string(cfg.rrf_k()); diff --git a/core/tests/test_advanced_modality_proto_abi.cpp b/core/tests/test_advanced_modality_proto_abi.cpp index 0161d7b15e..7f5593cabe 100644 --- a/core/tests/test_advanced_modality_proto_abi.cpp +++ b/core/tests/test_advanced_modality_proto_abi.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -2176,6 +2177,125 @@ int test_rag_search_retrieval_only() { CHECK(query_result.answer() == "mock answer", "rag.query still invokes the LLM"); rac_proto_buffer_free(&out); + // VECTOR_STORE_PGVECTOR rejection check at session create and search + runanywhere::v1::RAGConfiguration pgvector_config = full_config; + pgvector_config.set_vector_store(runanywhere::v1::VECTOR_STORE_PGVECTOR); + std::vector pgv_bytes; + CHECK(serialize(pgvector_config, &pgv_bytes), "pgvector RAGConfiguration serializes"); + rac_handle_t pgv_session = nullptr; + rc = rac_rag_session_create_proto(pgv_bytes.data(), pgv_bytes.size(), &pgv_session); + CHECK(rc == RAC_ERROR_INVALID_ARGUMENT, "VECTOR_STORE_PGVECTOR is rejected at session creation"); + + runanywhere::v1::RAGSearchRequest pgv_search; + pgv_search.set_query("Where does RAG live?"); + pgv_search.mutable_retrieval()->set_vector_store(runanywhere::v1::VECTOR_STORE_PGVECTOR); + std::vector pgv_search_bytes; + CHECK(serialize(pgv_search, &pgv_search_bytes), "pgvector search request serializes"); + rac_proto_buffer_init(&out); + rc = rac_rag_search_proto(full_session, pgv_search_bytes.data(), pgv_search_bytes.size(), &out); + CHECK(rc == RAC_ERROR_NOT_SUPPORTED, "VECTOR_STORE_PGVECTOR is rejected in rag.search"); + rac_proto_buffer_free(&out); + + // proto3 keeps unknown enum numbers; they must be rejected, not run against USearch. + runanywhere::v1::RAGSearchRequest unknown_store_search = pgv_search; + unknown_store_search.mutable_retrieval()->set_vector_store( + static_cast(42)); + std::vector unknown_store_bytes; + CHECK(serialize(unknown_store_search, &unknown_store_bytes), + "unknown vector_store search request serializes"); + rac_proto_buffer_init(&out); + rc = rac_rag_search_proto(full_session, unknown_store_bytes.data(), unknown_store_bytes.size(), + &out); + CHECK(rc == RAC_ERROR_INVALID_ARGUMENT, "unknown vector_store value is rejected in rag.search"); + rac_proto_buffer_free(&out); + + // BM25 and RRF retrieval tuning options are accepted on RAGSearchRequest + runanywhere::v1::RAGSearchRequest tuned_search; + tuned_search.set_query("Where does RAG live?"); + tuned_search.mutable_retrieval()->set_vector_store(runanywhere::v1::VECTOR_STORE_USEARCH); + tuned_search.mutable_retrieval()->set_bm25_k1(1.5f); + tuned_search.mutable_retrieval()->set_bm25_b(0.8f); + tuned_search.mutable_retrieval()->set_rrf_k(30); + std::vector tuned_search_bytes; + CHECK(serialize(tuned_search, &tuned_search_bytes), "tuned RAGSearchRequest serializes"); + rac_proto_buffer_init(&out); + rc = rac_rag_search_proto(full_session, tuned_search_bytes.data(), tuned_search_bytes.size(), &out); + CHECK(rc == RAC_SUCCESS, "rag.search with custom BM25/RRF params succeeds"); + rac_proto_buffer_free(&out); + + // The knobs must measurably change retrieval, not only be accepted. Two more chunks that + // match the query term: "tune/long" repeats it in a long chunk, "tune/short" is the bare + // term. Turning off BM25 length normalization (bm25_b = 0) moves the long chunk to the top + // of the BM25 ranking, and a small rrf_k reshapes every fused score. + for (const auto& [doc_id, doc_text] : std::vector>{ + {"tune/long", + "RAG RAG one two three four five six seven eight nine ten eleven twelve thirteen " + "fourteen fifteen sixteen seventeen eighteen"}, + {"tune/short", "RAG"}}) { + runanywhere::v1::RAGDocument tuning_doc; + tuning_doc.set_id(doc_id); + tuning_doc.set_text(doc_text); + std::vector tuning_doc_bytes; + CHECK(serialize(tuning_doc, &tuning_doc_bytes), "tuning corpus document serializes"); + rac_proto_buffer_init(&out); + CHECK(rac_rag_ingest_proto(full_session, tuning_doc_bytes.data(), tuning_doc_bytes.size(), + &out) == RAC_SUCCESS, + "tuning corpus document ingests"); + rac_proto_buffer_free(&out); + } + const auto fused_scores = [&](const runanywhere::v1::RAGRetrievalOptions& retrieval) { + runanywhere::v1::RAGSearchRequest request; + request.set_query("Where does RAG live?"); + *request.mutable_retrieval() = retrieval; + request.mutable_retrieval()->set_top_k(4); + std::map scores; + std::vector request_bytes; + if (!serialize(request, &request_bytes)) + return scores; + rac_proto_buffer_t response_buffer; + rac_proto_buffer_init(&response_buffer); + runanywhere::v1::RAGSearchResponse response; + if (rac_rag_search_proto(full_session, request_bytes.data(), request_bytes.size(), + &response_buffer) == RAC_SUCCESS && + parse_buffer(response_buffer, &response)) { + for (const auto& chunk : response.chunks()) + scores[chunk.text()] = chunk.score(); + } + rac_proto_buffer_free(&response_buffer); + return scores; + }; + const auto default_scores = fused_scores(runanywhere::v1::RAGRetrievalOptions()); + runanywhere::v1::RAGRetrievalOptions small_rrf_k; + small_rrf_k.set_rrf_k(1); + runanywhere::v1::RAGRetrievalOptions no_length_norm; + no_length_norm.set_bm25_b(0.0f); + const auto small_rrf_k_scores = fused_scores(small_rrf_k); + const auto no_length_norm_scores = fused_scores(no_length_norm); + CHECK(default_scores.size() == 3 && small_rrf_k_scores.size() == 3 && + no_length_norm_scores.size() == 3, + "tuning searches return the three matching chunks"); + CHECK(small_rrf_k_scores != default_scores, "rrf_k = 1 changes the fused scores"); + CHECK(no_length_norm_scores != default_scores, "bm25_b = 0 changes the fused scores"); + + // Out-of-range / non-finite BM25 tuning is rejected, not silently dropped at scoring. + runanywhere::v1::RAGSearchRequest bad_b_search = tuned_search; + bad_b_search.mutable_retrieval()->set_bm25_b(100.0f); + std::vector bad_b_bytes; + CHECK(serialize(bad_b_search, &bad_b_bytes), "bm25_b out-of-range request serializes"); + rac_proto_buffer_init(&out); + rc = rac_rag_search_proto(full_session, bad_b_bytes.data(), bad_b_bytes.size(), &out); + CHECK(rc == RAC_ERROR_INVALID_ARGUMENT, "rag.search rejects bm25_b outside [0, 1]"); + rac_proto_buffer_free(&out); + + runanywhere::v1::RAGSearchRequest inf_k1_search = tuned_search; + inf_k1_search.mutable_retrieval()->set_bm25_k1(std::numeric_limits::infinity()); + std::vector inf_k1_bytes; + CHECK(serialize(inf_k1_search, &inf_k1_bytes), "bm25_k1 infinite request serializes"); + rac_proto_buffer_init(&out); + rc = rac_rag_search_proto(full_session, inf_k1_bytes.data(), inf_k1_bytes.size(), &out); + CHECK(rc == RAC_ERROR_INVALID_ARGUMENT, "rag.search rejects non-finite bm25_k1"); + rac_proto_buffer_free(&out); + rac_rag_session_destroy_proto(full_session); (void)rac_plugin_unregister("onnx"); (void)rac_plugin_unregister("llamacpp"); diff --git a/core/tests/test_rag_fusion.cpp b/core/tests/test_rag_fusion.cpp index a376f4f435..dae579ee9a 100644 --- a/core/tests/test_rag_fusion.cpp +++ b/core/tests/test_rag_fusion.cpp @@ -13,8 +13,10 @@ #include #include +#include "features/rag/bm25_index.h" #include "features/rag/rag_fusion.h" +using runanywhere::rag::BM25Index; using runanywhere::rag::fuse_rankings; using runanywhere::rag::parse_query_variants; using runanywhere::rag::SearchResult; @@ -71,6 +73,36 @@ int main() { "fused score normalized to (0,1]"); } + // --- bm25_k1 / bm25_b measurably change BM25 scores --- + { + BM25Index index; + index.add_chunk("short", "apple apple apple"); + index.add_chunk("long", "apple banana cherry date elder fig grape honey iris jade kiwi lime"); + const auto base = index.search("apple", 2, 1.2f, 0.75f); + const auto high_k1 = index.search("apple", 2, 3.0f, 0.75f); + const auto no_len_norm = index.search("apple", 2, 1.2f, 0.0f); + const bool sized = base.size() == 2 && high_k1.size() == 2 && no_len_norm.size() == 2; + CHECK(sized, "BM25 returns both matching chunks"); + // Index only after the size check so a short result is a failure, not UB. + CHECK(sized && base[0].first == "short" && base[0].second != high_k1[0].second, + "bm25_k1 changes the term-frequency saturation score"); + CHECK(sized && base[1].second != no_len_norm[1].second, + "bm25_b changes the document-length normalization score"); + } + + // --- custom rrf_k alters score decay and ranking weights --- + { + // Compare ranking score ratio between rank 1 and rank 2 under default rrf_k (60) vs small rrf_k (1). + // For rrf_k=60: rank1 = 1/61, rank2 = 1/62. Ratio = 62/61 ~= 1.016 + // For rrf_k=1: rank1 = 1/2, rank2 = 1/3. Ratio = 3/2 = 1.5 + auto f_def = fuse_rankings({{"first", "second"}}, nullptr, 10, 60); + auto f_low = fuse_rankings({{"first", "second"}}, nullptr, 10, 1); + CHECK(f_def.size() == 2 && f_low.size() == 2, "rrf_k returns both items"); + float ratio_def = f_def[0].score / f_def[1].score; + float ratio_low = f_low[0].score / f_low[1].score; + CHECK(ratio_low > ratio_def + 0.3f, "lower rrf_k gives steeper rank decay"); + } + // --- empty input --- { auto f = fuse_rankings({}, nullptr, 10); diff --git a/core/tests/test_solution_runner.cpp b/core/tests/test_solution_runner.cpp index 5dd6250689..fa52aa03d5 100644 --- a/core/tests/test_solution_runner.cpp +++ b/core/tests/test_solution_runner.cpp @@ -4,6 +4,8 @@ #include #include +#include +#include #include #if defined(RAC_HAVE_PROTOBUF) @@ -1163,14 +1165,22 @@ TEST(rag_retrieval_params_reach_the_retrieve_operator) { CHECK(store != params.end()); CHECK(store->second == "VECTOR_STORE_USEARCH"); // Values, not just presence: a converter that forwarded the wrong number - // would satisfy a presence-only check. Compared against std::to_string so - // the assertion tracks the converter's own float formatting. + // would satisfy a presence-only check. Floats travel as classic-locale text + // that round-trips exactly, which is what the retrieve operator parses. + const auto parse_float = [](const std::string& text) { + std::istringstream stream(text); + stream.imbue(std::locale::classic()); + float value = -1.0f; + stream >> value; + return value; + }; const auto bm25_k1 = params.find("bm25_k1"); CHECK(bm25_k1 != params.end()); - CHECK(bm25_k1->second == std::to_string(1.5f)); + CHECK(bm25_k1->second == "1.5"); const auto bm25_b = params.find("bm25_b"); CHECK(bm25_b != params.end()); - CHECK(bm25_b->second == std::to_string(0.6f)); + CHECK(bm25_b->second.find(',') == std::string::npos); + CHECK(parse_float(bm25_b->second) == 0.6f); const auto rrf = params.find("rrf_k"); CHECK(rrf != params.end()); CHECK(rrf->second == "30"); @@ -1374,6 +1384,110 @@ TEST(retrieve_without_session_handle_fails_honestly) { rac::solutions::register_builtin_operators(registry); } +// --------------------------------------------------------------------------- +// 13b. RetrieveNode rejects VECTOR_STORE_PGVECTOR honestly. +// --------------------------------------------------------------------------- +TEST(retrieve_pgvector_is_rejected) { + // Needs the engine-backed retrieve factory registered, so the PGVECTOR + // guard it exercises is the one RetrieveNode actually installs. Same call + // as test 13 above, which is the only registration entry point here. + auto& registry = OperatorRegistry::instance(); + static_cast(rac::solutions::register_engine_backed_operators(registry)); + + PipelineSpec spec; + spec.set_name("retrieve_pgvector_rejection"); + auto* src = spec.add_operators(); + src->set_name("query"); + src->set_type("source"); + auto* retrieve = spec.add_operators(); + retrieve->set_name("retrieve"); + retrieve->set_type("retrieve"); + (*retrieve->mutable_params())["session_handle_id"] = "12345"; + (*retrieve->mutable_params())["vector_store"] = "VECTOR_STORE_PGVECTOR"; + auto* sink = spec.add_operators(); + sink->set_name("ctx"); + sink->set_type("sink"); + auto* e1 = spec.add_edges(); + e1->set_from("query.out"); + e1->set_to("retrieve.in"); + auto* e2 = spec.add_edges(); + e2->set_from("retrieve.results"); + e2->set_to("ctx.in"); + + SolutionRunner runner(spec); + CHECK(runner.start() == RAC_SUCCESS); + CHECK(runner.feed(Item::text("why is the sky blue?")) == RAC_SUCCESS); + runner.wait(); + const char* details = rac_error_get_details(); + CHECK(details != nullptr); + CHECK(std::string(details).find("VECTOR_STORE_PGVECTOR is not supported on-device") != + std::string::npos); + + registry.unregister_factory("retrieve"); + rac::solutions::register_builtin_operators(registry); +} + +// --------------------------------------------------------------------------- +// 13c. RetrieveNode rejects malformed numeric params and unknown vector stores +// instead of falling back to defaults. +// --------------------------------------------------------------------------- +TEST(retrieve_rejects_malformed_params) { + auto& registry = OperatorRegistry::instance(); + static_cast(rac::solutions::register_engine_backed_operators(registry)); + + struct Case { + const char* key; + const char* value; + const char* expected; + }; + const Case cases[] = { + {"k", "30junk", "fully-numeric"}, + {"bm25_k1", "0,8", "fully-numeric"}, + {"bm25_k1", "inf", "fully-numeric"}, + {"bm25_b", "100", "0.0...1.0"}, + {"rrf_k", "-5", "rrf_k"}, + {"bm25_b", "-0.5", "0.0...1.0"}, + {"k", "0", "'k' must be >= 1"}, + {"similarity_threshold", "1.5", "similarity_threshold"}, + {"vector_store", "PGVECTOR", "unknown vector_store"}, + {"vector_store", "usearch ", "unknown vector_store"}, + }; + for (const Case& c : cases) { + // A case that records no detail must not pass on the previous case's message. + rac_error_clear_details(); + PipelineSpec spec; + spec.set_name("retrieve_param_rejection"); + auto* src = spec.add_operators(); + src->set_name("query"); + src->set_type("source"); + auto* retrieve = spec.add_operators(); + retrieve->set_name("retrieve"); + retrieve->set_type("retrieve"); + (*retrieve->mutable_params())["session_handle_id"] = "12345"; + (*retrieve->mutable_params())[c.key] = c.value; + auto* sink = spec.add_operators(); + sink->set_name("ctx"); + sink->set_type("sink"); + auto* e1 = spec.add_edges(); + e1->set_from("query.out"); + e1->set_to("retrieve.in"); + auto* e2 = spec.add_edges(); + e2->set_from("retrieve.results"); + e2->set_to("ctx.in"); + + SolutionRunner runner(spec); + CHECK(runner.start() == RAC_SUCCESS); + CHECK(runner.feed(Item::text("why is the sky blue?")) == RAC_SUCCESS); + runner.wait(); + const char* details = rac_error_get_details(); + CHECK(details != nullptr); + CHECK(std::string(details).find(c.expected) != std::string::npos); + } + + registry.unregister_factory("retrieve"); + rac::solutions::register_builtin_operators(registry); +} + // --------------------------------------------------------------------------- // 14. Null / invalid handle paths. // --------------------------------------------------------------------------- @@ -1422,6 +1536,8 @@ int main() { run_test_c_abi_yaml_solution_lifecycle(); run_test_c_abi_yaml_pipeline_lifecycle(); run_test_retrieve_without_session_handle_fails_honestly(); + run_test_retrieve_pgvector_is_rejected(); + run_test_retrieve_rejects_malformed_params(); run_test_null_handle_paths(); std::fprintf(stderr, "\n%d passed / %d failed\n", g_passed, g_failed); diff --git a/idl/SCHEMA_LOCK b/idl/SCHEMA_LOCK index 133dd793f5..5428238379 100644 --- a/idl/SCHEMA_LOCK +++ b/idl/SCHEMA_LOCK @@ -12,7 +12,7 @@ # # Verify: idl/codegen/schema_lock.sh --check # ============================================================================= -IDL_VERSION=1.6.1 +IDL_VERSION=1.7.0 IDL_PROTO_COUNT=41 -IDL_SCHEMA_SHA256=688706bfa5ac286fd28ed5f21a1dca1a1fa0ff0e0d5a8f6d4cffc313b9e46d0a +IDL_SCHEMA_SHA256=227e5092537692d1ebcba2ab67284014b0a3eff0dc8a380474860f1c64eb9757 IDL_PROTOC_VERSION=35.1 diff --git a/idl/VERSION b/idl/VERSION index 9c6d6293b1..bd8bf882d0 100644 --- a/idl/VERSION +++ b/idl/VERSION @@ -1 +1 @@ -1.6.1 +1.7.0 diff --git a/idl/rag.proto b/idl/rag.proto index cf369f7556..5d936dcdf0 100644 --- a/idl/rag.proto +++ b/idl/rag.proto @@ -22,6 +22,12 @@ option csharp_namespace = "Runanywhere.V1"; option swift_prefix = "RA"; option go_package = "github.com/runanywhere/runanywhere-sdks/idl/v1;runanywherev1"; +enum VectorStore { + VECTOR_STORE_UNSPECIFIED = 0; + VECTOR_STORE_USEARCH = 1; // in-process HNSW + VECTOR_STORE_PGVECTOR = 2; // server deployments only, no on-device path +} + message RAGConfiguration { string embedding_model_id = 1; string llm_model_id = 2; @@ -56,6 +62,16 @@ message RAGConfiguration { // Pointwise rerank of the retrieved chunks using the session LLM. bool rerank_results = 11; + + // Vector store backend selection (e.g. VECTOR_STORE_USEARCH). + optional VectorStore vector_store = 12; + + // BM25 term-saturation and length-normalization parameters. + optional float bm25_k1 = 13; + optional float bm25_b = 14; + + // Reciprocal-rank-fusion smoothing constant. + optional int32 rrf_k = 15; } message RAGDocument { @@ -106,6 +122,12 @@ message RAGRetrievalOptions { // Keep only chunks whose document id starts with this prefix. optional string scope_prefix = 5; + + // Optional per-query retrieval overrides for BM25, RRF, and vector store. + optional float bm25_k1 = 6; + optional float bm25_b = 7; + optional int32 rrf_k = 8; + optional VectorStore vector_store = 9; } message RAGQueryOptions { diff --git a/idl/solutions.proto b/idl/solutions.proto index 04f9bfd50f..fcd88f3de9 100644 --- a/idl/solutions.proto +++ b/idl/solutions.proto @@ -8,6 +8,7 @@ syntax = "proto3"; package runanywhere.v1; import "llm_options.proto"; +import "rag.proto"; option cc_enable_arenas = true; option java_multiple_files = true; @@ -100,12 +101,6 @@ message RAGConfig { string prompt_template = 11; } -enum VectorStore { - VECTOR_STORE_UNSPECIFIED = 0; - VECTOR_STORE_USEARCH = 1; // in-process HNSW - VECTOR_STORE_PGVECTOR = 2; // server deployments only, no on-device path -} - message AgentLoopConfig { string llm_model_id = 1; string system_prompt = 2; From cc5f932cc8519ca375adc7ed07002573bd3928f4 Mon Sep 17 00:00:00 2001 From: Aditya kumar singh <143548997+Adityakk9031@users.noreply.github.com> Date: Thu, 22 Oct 2026 11:40:10 +0530 Subject: [PATCH 2/5] feat(bindings): expose BM25, RRF and vector-store knobs on RAG query options (#1019) --- .../sdk/public/api/MappingOptions.kt | 9 +++++ .../com/runanywhere/sdk/public/api/Options.kt | 13 +++++++ .../public/api/RagSearchRequestMappingTest.kt | 24 +++++++++++++ bindings/python/runanywhere/__init__.py | 2 ++ bindings/python/runanywhere/_rag_bridge.py | 35 ++++++++++++++++++- bindings/python/runanywhere/options.py | 14 ++++++++ bindings/python/tests/test_rag.py | 33 +++++++++++++++++ .../RunAnywhere/Public/API/Handles.swift | 23 +++++++++++- .../Public/API/Rag/RagSession.swift | 27 +++++++++++--- .../core/src/Public/API/Namespaces/rag.ts | 12 ++++++- .../packages/core/src/Public/API/Options.ts | 10 ++++++ .../src/Public/Extensions/RunAnywhere+RAG.ts | 4 +++ bindings/web/packages/core/src/index.ts | 1 + 13 files changed, 200 insertions(+), 7 deletions(-) diff --git a/bindings/kotlin/src/main/kotlin/com/runanywhere/sdk/public/api/MappingOptions.kt b/bindings/kotlin/src/main/kotlin/com/runanywhere/sdk/public/api/MappingOptions.kt index 6a09b9c746..60d34f9d17 100644 --- a/bindings/kotlin/src/main/kotlin/com/runanywhere/sdk/public/api/MappingOptions.kt +++ b/bindings/kotlin/src/main/kotlin/com/runanywhere/sdk/public/api/MappingOptions.kt @@ -28,6 +28,7 @@ import ai.runanywhere.proto.v1.ToolChoiceMode import ai.runanywhere.proto.v1.VADOptions import ai.runanywhere.proto.v1.VLMGenerationRequest import ai.runanywhere.proto.v1.VLMImage +import ai.runanywhere.proto.v1.VectorStore import com.runanywhere.sdk.generated.convenience.defaults import ai.runanywhere.proto.v1.ChatMessage as ProtoChatMessage import ai.runanywhere.proto.v1.DiarizationOptions as ProtoDiarizationOptions @@ -252,6 +253,14 @@ internal fun ragQueryProto( top_k = options?.retrieval?.topK ?: config.topK, score_threshold = options?.retrieval?.similarityThreshold ?: config.similarityThreshold, enable_multi_query = config.multiQuery, + bm25_k1 = options?.retrieval?.bm25K1, + bm25_b = options?.retrieval?.bm25B, + rrf_k = options?.retrieval?.rrfK, + vector_store = + when (options?.retrieval?.vectorStore) { + RagVectorStore.USEARCH -> VectorStore.VECTOR_STORE_USEARCH + null -> null + }, ), generation = options?.generation?.toProto(), ) diff --git a/bindings/kotlin/src/main/kotlin/com/runanywhere/sdk/public/api/Options.kt b/bindings/kotlin/src/main/kotlin/com/runanywhere/sdk/public/api/Options.kt index d8b7d8f8a3..a8f3c86505 100644 --- a/bindings/kotlin/src/main/kotlin/com/runanywhere/sdk/public/api/Options.kt +++ b/bindings/kotlin/src/main/kotlin/com/runanywhere/sdk/public/api/Options.kt @@ -258,10 +258,23 @@ public data class TurnHandlingOptions( val interruption: Interruption = Interruption(), ) +/** Vector store backing RAG retrieval. Only the in-process store is available on-device. */ +public enum class RagVectorStore { + /** In-process HNSW index. */ + USEARCH, +} + /** Per-query retrieval overrides for `RagSession.query`/`queryStream`. */ public data class RagRetrievalOptions( val topK: Int? = null, val similarityThreshold: Float? = null, + /** BM25 term-frequency saturation; finite and `>= 0`. Null uses the session default. */ + val bm25K1: Float? = null, + /** BM25 document-length normalization in `0..1`. Null uses the session default. */ + val bm25B: Float? = null, + /** Reciprocal-rank-fusion constant, `>= 0`. Null uses the session default. */ + val rrfK: Int? = null, + val vectorStore: RagVectorStore? = null, ) /** Per-query knobs for `RagSession.query`/`queryStream`. */ diff --git a/bindings/kotlin/src/test/kotlin/com/runanywhere/sdk/public/api/RagSearchRequestMappingTest.kt b/bindings/kotlin/src/test/kotlin/com/runanywhere/sdk/public/api/RagSearchRequestMappingTest.kt index f1cfabcc21..d3d09d7682 100644 --- a/bindings/kotlin/src/test/kotlin/com/runanywhere/sdk/public/api/RagSearchRequestMappingTest.kt +++ b/bindings/kotlin/src/test/kotlin/com/runanywhere/sdk/public/api/RagSearchRequestMappingTest.kt @@ -17,6 +17,7 @@ import ai.runanywhere.proto.v1.RAGRetrievalOptions import ai.runanywhere.proto.v1.RAGSearchRequest import ai.runanywhere.proto.v1.RAGSearchResponse import ai.runanywhere.proto.v1.RAGSearchResult +import ai.runanywhere.proto.v1.VectorStore import com.runanywhere.sdk.foundation.bridge.extensions.CppBridgeRAG import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue @@ -38,6 +39,29 @@ class RagSearchRequestMappingTest { assertEquals(false, decoded.retrieval?.enable_multi_query) } + @Test + fun queryProtoCarriesBm25RrfAndVectorStoreOverrides() { + val proto = + ragQueryProto( + question = "where is rag", + config = RagConfig(), + options = + RagQueryOptions( + retrieval = + RagRetrievalOptions( + bm25K1 = 1.5f, + bm25B = 0.8f, + rrfK = 30, + vectorStore = RagVectorStore.USEARCH, + ), + ), + ) + assertEquals(1.5f, proto.retrieval?.bm25_k1) + assertEquals(0.8f, proto.retrieval?.bm25_b) + assertEquals(30, proto.retrieval?.rrf_k) + assertEquals(VectorStore.VECTOR_STORE_USEARCH, proto.retrieval?.vector_store) + } + @Test fun searchResponseChunksMapToMatches() { val response = diff --git a/bindings/python/runanywhere/__init__.py b/bindings/python/runanywhere/__init__.py index 065096e96b..5255d10ef4 100644 --- a/bindings/python/runanywhere/__init__.py +++ b/bindings/python/runanywhere/__init__.py @@ -105,6 +105,7 @@ RagConfig, RagQueryOptions, RagRetrievalOptions, + RagVectorStore, ReasoningMode, ReasoningOptions, SegmentationOptions, @@ -445,6 +446,7 @@ def capabilities() -> SDKCapabilities: "RagConfig", "RagQueryOptions", "RagRetrievalOptions", + "RagVectorStore", "ReasoningMode", "ReasoningOptions", "SegmentationOptions", diff --git a/bindings/python/runanywhere/_rag_bridge.py b/bindings/python/runanywhere/_rag_bridge.py index 065dc0352e..32c27a4726 100644 --- a/bindings/python/runanywhere/_rag_bridge.py +++ b/bindings/python/runanywhere/_rag_bridge.py @@ -13,7 +13,7 @@ from .errors import ErrorCode, SDKException from .events import RagEvent, RagEventKind -from .options import RagConfig, RagQueryOptions, ReasoningMode +from .options import RagConfig, RagQueryOptions, RagVectorStore, ReasoningMode from .results import Match, RagResult, RagStats, TokenKind try: @@ -100,6 +100,31 @@ def build_document(text: str, doc_id: str, metadata: Optional[dict]) -> bytes: return doc.SerializeToString() +def _int32_option(name: str, value: Any) -> int: + """Return ``value`` for an int32 proto field, rejecting non-integers and overflow. + + ``int()`` would truncate ``-0.5`` to ``0`` and wrap nothing, so the native + validator would never see what the caller actually passed. + """ + if isinstance(value, bool) or not isinstance(value, int): + raise SDKException.invalid_input( + f"RagRetrievalOptions.{name} must be an int, got {type(value).__name__}" + ) + if not -(2**31) <= value < 2**31: + raise SDKException.invalid_input(f"RagRetrievalOptions.{name} must fit int32, got {value}") + return value + + +def _vector_store_option(value: Any) -> int: + """Return the wire value of a :class:`RagVectorStore`, rejecting anything else.""" + try: + return int(RagVectorStore(value)) + except ValueError: + raise SDKException.invalid_input( + f"RagRetrievalOptions.vector_store must be a RagVectorStore, got {value!r}" + ) from None + + def build_query( question: str, options: Optional[RagQueryOptions], @@ -113,6 +138,14 @@ def build_query( query.retrieval.top_k = int(retrieval.top_k) if retrieval.similarity_threshold is not None: query.retrieval.score_threshold = retrieval.similarity_threshold + if retrieval.bm25_k1 is not None: + query.retrieval.bm25_k1 = retrieval.bm25_k1 + if retrieval.bm25_b is not None: + query.retrieval.bm25_b = retrieval.bm25_b + if retrieval.rrf_k is not None: + query.retrieval.rrf_k = _int32_option("rrf_k", retrieval.rrf_k) + if retrieval.vector_store is not None: + query.retrieval.vector_store = _vector_store_option(retrieval.vector_store) generation = options.generation if generation is not None: query.generation.max_output_tokens = generation.max_output_tokens diff --git a/bindings/python/runanywhere/options.py b/bindings/python/runanywhere/options.py index cf9471fef2..48e6e8b5c1 100644 --- a/bindings/python/runanywhere/options.py +++ b/bindings/python/runanywhere/options.py @@ -31,6 +31,7 @@ "RagConfig", "RagQueryOptions", "RagRetrievalOptions", + "RagVectorStore", "ReasoningMode", "ReasoningOptions", "SegmentationOptions", @@ -296,12 +297,25 @@ class RagConfig: persist_path: Optional[str] = None +class RagVectorStore(IntEnum): + """Vector store backing RAG retrieval; only the in-process store exists on-device.""" + + USEARCH = 1 + + @dataclass class RagRetrievalOptions: """Per-query retrieval overrides for ``RagSession.query``/``query_stream``.""" top_k: Optional[int] = None similarity_threshold: Optional[float] = None + #: BM25 term-frequency saturation; finite and ``>= 0``. ``None`` uses the session default. + bm25_k1: Optional[float] = None + #: BM25 document-length normalization in ``0..1``. ``None`` uses the session default. + bm25_b: Optional[float] = None + #: Reciprocal-rank-fusion constant, ``>= 0``. ``None`` uses the session default. + rrf_k: Optional[int] = None + vector_store: Optional[RagVectorStore] = None @dataclass diff --git a/bindings/python/tests/test_rag.py b/bindings/python/tests/test_rag.py index 9ea15c696b..e971e7cacf 100644 --- a/bindings/python/tests/test_rag.py +++ b/bindings/python/tests/test_rag.py @@ -32,6 +32,7 @@ RagQueryOptions, RagResult, RagRetrievalOptions, + RagVectorStore, RagSession, ReasoningMode, ReasoningOptions, @@ -241,6 +242,38 @@ def test_query_accepts_rag_query_options_with_retrieval_overrides(rag_core) -> N assert query.generation.max_output_tokens == 64 +def test_query_threads_bm25_rrf_and_vector_store_into_the_proto(rag_core) -> None: + session = _open(rag_core) + session.ingest(RagDocument("Paris is the capital of France.")) + session.query( + "Capital of France?", + RagQueryOptions( + retrieval=RagRetrievalOptions( + bm25_k1=1.5, bm25_b=0.8, rrf_k=30, vector_store=RagVectorStore.USEARCH + ), + ), + ) + retrieval = rag_core.last_query.retrieval + assert retrieval.bm25_k1 == pytest.approx(1.5) + assert retrieval.bm25_b == pytest.approx(0.8) + assert retrieval.rrf_k == 30 + assert retrieval.vector_store == RagVectorStore.USEARCH + + +@pytest.mark.parametrize( + "retrieval", + [ + RagRetrievalOptions(rrf_k=-0.5), # type: ignore[arg-type] + RagRetrievalOptions(rrf_k=2**31), + RagRetrievalOptions(vector_store=7), # type: ignore[arg-type] + ], +) +def test_query_rejects_malformed_retrieval_knobs_before_serializing(rag_core, retrieval) -> None: + session = _open(rag_core) + with pytest.raises(SDKException): + session.query("Capital of France?", RagQueryOptions(retrieval=retrieval)) + + def test_query_forwards_every_rag_generation_knob(rag_core) -> None: session = _open(rag_core) session.ingest(RagDocument("Paris is the capital of France.")) diff --git a/bindings/swift/Sources/RunAnywhere/Public/API/Handles.swift b/bindings/swift/Sources/RunAnywhere/Public/API/Handles.swift index 858987342b..29cbbfe0c1 100644 --- a/bindings/swift/Sources/RunAnywhere/Public/API/Handles.swift +++ b/bindings/swift/Sources/RunAnywhere/Public/API/Handles.swift @@ -316,20 +316,41 @@ public enum StructuredEnforcementMode: Sendable, Equatable { case repair } +/// Vector store backing RAG retrieval. Only the in-process store is available on-device. +public enum RagVectorStore: Sendable, Equatable { + /// In-process HNSW index. + case usearch +} + /// Retrieval + generation options for one RAG query. public struct RagQueryOptions: Sendable { public var retrievalTopK: Int? public var similarityThreshold: Float? public var generation: LlmOptions? + /// BM25 term-frequency saturation; finite and `>= 0`. Unset uses the session default. + public var bm25K1: Float? + /// BM25 document-length normalization in `0...1`. Unset uses the session default. + public var bm25B: Float? + /// Reciprocal-rank-fusion constant, `>= 0`. Unset uses the session default. + public var rrfK: Int? + public var vectorStore: RagVectorStore? public init( retrievalTopK: Int? = nil, similarityThreshold: Float? = nil, - generation: LlmOptions? = nil + generation: LlmOptions? = nil, + bm25K1: Float? = nil, + bm25B: Float? = nil, + rrfK: Int? = nil, + vectorStore: RagVectorStore? = nil ) { self.retrievalTopK = retrievalTopK self.similarityThreshold = similarityThreshold self.generation = generation + self.bm25K1 = bm25K1 + self.bm25B = bm25B + self.rrfK = rrfK + self.vectorStore = vectorStore } } diff --git a/bindings/swift/Sources/RunAnywhere/Public/API/Rag/RagSession.swift b/bindings/swift/Sources/RunAnywhere/Public/API/Rag/RagSession.swift index 2d4badc305..b8148875ce 100644 --- a/bindings/swift/Sources/RunAnywhere/Public/API/Rag/RagSession.swift +++ b/bindings/swift/Sources/RunAnywhere/Public/API/Rag/RagSession.swift @@ -75,7 +75,7 @@ public actor RagSession { try requireGenerationModel() let result = try CppBridge.RAG.shared.query( handle: handle, - queryOptions(question: question, options: options) + try queryOptions(question: question, options: options) ) try RagSession.throwIfFailed(result) return RagResult(proto: result, model: llmModelId) @@ -100,7 +100,7 @@ public actor RagSession { let sessionHandle = RagSessionHandleRef(handle: handle) let events = try CppBridge.RAG.shared.runQueryStream( handle: handle, - queryOptions(question: question, options: options) + try queryOptions(question: question, options: options) ) let model = llmModelId @@ -209,15 +209,23 @@ public actor RagSession { // MARK: - Private - private func queryOptions(question: String, options: RagQueryOptions?) -> RARAGQueryOptions { + private func queryOptions(question: String, options: RagQueryOptions?) throws -> RARAGQueryOptions { var queryOptions = RARAGQueryOptions.defaults(question: question) // retrievalTopK/similarityThreshold moved onto the nested // `RAGRetrievalOptions retrieval` sub-message (idl/rag.proto). var retrieval = RARAGRetrievalOptions() - retrieval.topK = Int32(options?.retrievalTopK ?? defaultTopK) + retrieval.topK = try Self.int32Field(options?.retrievalTopK ?? defaultTopK, "retrievalTopK") if let similarityThreshold = options?.similarityThreshold { retrieval.scoreThreshold = similarityThreshold } + if let bm25K1 = options?.bm25K1 { retrieval.bm25K1 = bm25K1 } + if let bm25B = options?.bm25B { retrieval.bm25B = bm25B } + if let rrfK = options?.rrfK { retrieval.rrfK = try Self.int32Field(rrfK, "rrfK") } + if let vectorStore = options?.vectorStore { + switch vectorStore { + case .usearch: retrieval.vectorStore = .usearch + } + } queryOptions.retrieval = retrieval if let generation = options?.generation { queryOptions.generation = generation.toProto() @@ -225,6 +233,17 @@ public actor RagSession { return queryOptions } + /// Narrow a caller-supplied `Int` to its int32 wire field, throwing instead of trapping + /// when it does not fit (commons then range-checks the value itself). + private static func int32Field(_ value: Int, _ name: String) throws -> Int32 { + guard let wire = Int32(exactly: value) else { + throw SDKException.invalidConfiguration( + "RagQueryOptions.\(name) must fit RAGRetrievalOptions (int32); got \(value)" + ) + } + return wire + } + private func requireOpen() throws { guard !isClosed else { throw SDKException( diff --git a/bindings/web/packages/core/src/Public/API/Namespaces/rag.ts b/bindings/web/packages/core/src/Public/API/Namespaces/rag.ts index 4e3c13a933..d36f33c4f2 100644 --- a/bindings/web/packages/core/src/Public/API/Namespaces/rag.ts +++ b/bindings/web/packages/core/src/Public/API/Namespaces/rag.ts @@ -2,7 +2,7 @@ * `RunAnywhere.rag` — retrieval-augmented sessions over an ingested corpus. */ -import { RAGStreamEventKind } from '@runanywhere/proto-ts/rag'; +import { RAGStreamEventKind, VectorStore } from '@runanywhere/proto-ts/rag'; import type { RAGQueryOverrides } from '../../Extensions/RunAnywhere+RAG.js'; import { SDKException } from '../../../Foundation/SDKException.js'; import { @@ -66,11 +66,21 @@ function isRagQueryOptions( function normalizeQueryOptions(options?: RagQueryOptions | LlmOptions): RAGQueryOverrides { if (!options) return {}; if (isRagQueryOptions(options)) { + const vectorStore: unknown = options.retrieval?.vectorStore; + if (vectorStore !== undefined && vectorStore !== 'usearch') { + throw SDKException.invalidConfiguration( + `RagRetrievalOptions.vectorStore must be 'usearch' (the only on-device store); got ${String(vectorStore)}`, + ); + } return { retrieval: { topK: options.retrieval?.topK, scoreThreshold: options.retrieval?.similarityThreshold, enableMultiQuery: false, + bm25K1: options.retrieval?.bm25K1, + bm25B: options.retrieval?.bm25B, + rrfK: options.retrieval?.rrfK, + vectorStore: vectorStore === 'usearch' ? VectorStore.VECTOR_STORE_USEARCH : undefined, }, generation: options.generation ? toProtoLlmOptions(options.generation) : undefined, }; diff --git a/bindings/web/packages/core/src/Public/API/Options.ts b/bindings/web/packages/core/src/Public/API/Options.ts index 0b0f6fd245..7cd3cfdeb8 100644 --- a/bindings/web/packages/core/src/Public/API/Options.ts +++ b/bindings/web/packages/core/src/Public/API/Options.ts @@ -200,10 +200,20 @@ export interface RagConfig { persistPath?: string; } +/** Vector store backing RAG retrieval. Only the in-process store is available on-device. */ +export type RagVectorStore = 'usearch'; + /** Per-query retrieval overrides of `RagSession.query`/`queryStream`. */ export interface RagRetrievalOptions { topK?: number; similarityThreshold?: number; + /** BM25 term-frequency saturation; finite and `>= 0`. Unset uses the session default. */ + bm25K1?: number; + /** BM25 document-length normalization in `0..1`. Unset uses the session default. */ + bm25B?: number; + /** Reciprocal-rank-fusion constant, `>= 0`. Unset uses the session default. */ + rrfK?: number; + vectorStore?: RagVectorStore; } /** Per-query knobs of `RagSession.query`/`queryStream`. */ diff --git a/bindings/web/packages/core/src/Public/Extensions/RunAnywhere+RAG.ts b/bindings/web/packages/core/src/Public/Extensions/RunAnywhere+RAG.ts index be4cf450eb..886f1ef003 100644 --- a/bindings/web/packages/core/src/Public/Extensions/RunAnywhere+RAG.ts +++ b/bindings/web/packages/core/src/Public/Extensions/RunAnywhere+RAG.ts @@ -1497,6 +1497,10 @@ function makeRAGQuery( enableMultiQuery: options.retrieval?.enableMultiQuery ?? retrievalDefaults.enableMultiQuery, multiQueryCount: options.retrieval?.multiQueryCount ?? retrievalDefaults.multiQueryCount, scopePrefix: options.retrieval?.scopePrefix, + bm25K1: options.retrieval?.bm25K1, + bm25B: options.retrieval?.bm25B, + rrfK: options.retrieval?.rrfK, + vectorStore: options.retrieval?.vectorStore, }, }; } diff --git a/bindings/web/packages/core/src/index.ts b/bindings/web/packages/core/src/index.ts index 2d6dbc3dda..c868149e5f 100644 --- a/bindings/web/packages/core/src/index.ts +++ b/bindings/web/packages/core/src/index.ts @@ -59,6 +59,7 @@ export type { RagConfig, RagQueryOptions, RagRetrievalOptions, + RagVectorStore, ReasoningOptions, SegmentationOptions, SttOptions, From 32f9250f568e820b694d85712939932667eceb03 Mon Sep 17 00:00:00 2001 From: Aditya kumar singh <143548997+Adityakk9031@users.noreply.github.com> Date: Thu, 22 Oct 2026 11:40:11 +0530 Subject: [PATCH 3/5] fix(rag): reject explicit bm25_k1 = 0 and rrf_k = 0 instead of substituting defaults (#1056) --- .../com/runanywhere/sdk/public/api/Options.kt | 4 ++-- bindings/python/runanywhere/options.py | 4 ++-- .../RunAnywhere/Public/API/Handles.swift | 4 ++-- .../packages/core/src/Public/API/Options.ts | 4 ++-- core/src/features/rag/rac_rag_proto_abi.cpp | 11 ++++++---- .../solutions/operators/op_engine_backed.cpp | 20 +++++++++---------- .../test_advanced_modality_proto_abi.cpp | 20 +++++++++++++++++++ core/tests/test_solution_runner.cpp | 2 ++ 8 files changed, 47 insertions(+), 22 deletions(-) diff --git a/bindings/kotlin/src/main/kotlin/com/runanywhere/sdk/public/api/Options.kt b/bindings/kotlin/src/main/kotlin/com/runanywhere/sdk/public/api/Options.kt index a8f3c86505..5803a51398 100644 --- a/bindings/kotlin/src/main/kotlin/com/runanywhere/sdk/public/api/Options.kt +++ b/bindings/kotlin/src/main/kotlin/com/runanywhere/sdk/public/api/Options.kt @@ -268,11 +268,11 @@ public enum class RagVectorStore { public data class RagRetrievalOptions( val topK: Int? = null, val similarityThreshold: Float? = null, - /** BM25 term-frequency saturation; finite and `>= 0`. Null uses the session default. */ + /** BM25 term-frequency saturation; finite and `> 0` (0 is rejected). Null uses the session default. */ val bm25K1: Float? = null, /** BM25 document-length normalization in `0..1`. Null uses the session default. */ val bm25B: Float? = null, - /** Reciprocal-rank-fusion constant, `>= 0`. Null uses the session default. */ + /** Reciprocal-rank-fusion constant, `> 0` (0 is rejected). Null uses the session default. */ val rrfK: Int? = null, val vectorStore: RagVectorStore? = null, ) diff --git a/bindings/python/runanywhere/options.py b/bindings/python/runanywhere/options.py index 48e6e8b5c1..1becae56ec 100644 --- a/bindings/python/runanywhere/options.py +++ b/bindings/python/runanywhere/options.py @@ -309,11 +309,11 @@ class RagRetrievalOptions: top_k: Optional[int] = None similarity_threshold: Optional[float] = None - #: BM25 term-frequency saturation; finite and ``>= 0``. ``None`` uses the session default. + #: BM25 term-frequency saturation; finite and ``> 0`` (0 is rejected). ``None`` uses the session default. bm25_k1: Optional[float] = None #: BM25 document-length normalization in ``0..1``. ``None`` uses the session default. bm25_b: Optional[float] = None - #: Reciprocal-rank-fusion constant, ``>= 0``. ``None`` uses the session default. + #: Reciprocal-rank-fusion constant, ``> 0`` (0 is rejected). ``None`` uses the session default. rrf_k: Optional[int] = None vector_store: Optional[RagVectorStore] = None diff --git a/bindings/swift/Sources/RunAnywhere/Public/API/Handles.swift b/bindings/swift/Sources/RunAnywhere/Public/API/Handles.swift index 29cbbfe0c1..e13f65e9a9 100644 --- a/bindings/swift/Sources/RunAnywhere/Public/API/Handles.swift +++ b/bindings/swift/Sources/RunAnywhere/Public/API/Handles.swift @@ -327,11 +327,11 @@ public struct RagQueryOptions: Sendable { public var retrievalTopK: Int? public var similarityThreshold: Float? public var generation: LlmOptions? - /// BM25 term-frequency saturation; finite and `>= 0`. Unset uses the session default. + /// BM25 term-frequency saturation; finite and `> 0` (0 is rejected). Unset uses the session default. public var bm25K1: Float? /// BM25 document-length normalization in `0...1`. Unset uses the session default. public var bm25B: Float? - /// Reciprocal-rank-fusion constant, `>= 0`. Unset uses the session default. + /// Reciprocal-rank-fusion constant, `> 0` (0 is rejected). Unset uses the session default. public var rrfK: Int? public var vectorStore: RagVectorStore? diff --git a/bindings/web/packages/core/src/Public/API/Options.ts b/bindings/web/packages/core/src/Public/API/Options.ts index 7cd3cfdeb8..91e41c9271 100644 --- a/bindings/web/packages/core/src/Public/API/Options.ts +++ b/bindings/web/packages/core/src/Public/API/Options.ts @@ -207,11 +207,11 @@ export type RagVectorStore = 'usearch'; export interface RagRetrievalOptions { topK?: number; similarityThreshold?: number; - /** BM25 term-frequency saturation; finite and `>= 0`. Unset uses the session default. */ + /** BM25 term-frequency saturation; finite and `> 0` (0 is rejected). Unset uses the session default. */ bm25K1?: number; /** BM25 document-length normalization in `0..1`. Unset uses the session default. */ bm25B?: number; - /** Reciprocal-rank-fusion constant, `>= 0`. Unset uses the session default. */ + /** Reciprocal-rank-fusion constant, `> 0` (0 is rejected). Unset uses the session default. */ rrfK?: number; vectorStore?: RagVectorStore; } diff --git a/core/src/features/rag/rac_rag_proto_abi.cpp b/core/src/features/rag/rac_rag_proto_abi.cpp index 736df9ee44..70b2977206 100644 --- a/core/src/features/rag/rac_rag_proto_abi.cpp +++ b/core/src/features/rag/rac_rag_proto_abi.cpp @@ -332,6 +332,9 @@ RAGBackendConfig build_backend_config(const runanywhere::v1::RAGConfiguration& p // Rejects non-finite or out-of-range BM25 / RRF tuning values at the ABI boundary: // b outside [0, 1] or an infinite k1 makes every BM25 score drop out of the // `score > 0` filter (negative or NaN), which would silently return no hits. +// k1 and rrf_k must be strictly positive: 0 is the "unset" sentinel all the way down +// (QueryOverrides, BM25Index, fuse_rankings), so an explicit 0 would silently become the +// default (1.2 / 60) instead of doing what it says. bool validate_bm25_rrf_params(bool has_k1, float k1, bool has_b, float b, bool has_rrf_k, int32_t rrf_k, const char* owner, std::string* out_message) { const auto fail = [&](const char* detail) { @@ -339,12 +342,12 @@ bool validate_bm25_rrf_params(bool has_k1, float k1, bool has_b, float b, bool h *out_message = std::string(owner) + "." + detail; return false; }; - if (has_k1 && (!std::isfinite(k1) || k1 < 0.0f)) - return fail("bm25_k1 must be a finite value >= 0"); + if (has_k1 && (!std::isfinite(k1) || k1 <= 0.0f)) + return fail("bm25_k1 must be a finite value > 0"); if (has_b && (!std::isfinite(b) || b < 0.0f || b > 1.0f)) return fail("bm25_b must be a finite value in 0.0...1.0"); - if (has_rrf_k && rrf_k < 0) - return fail("rrf_k must be >= 0"); + if (has_rrf_k && rrf_k <= 0) + return fail("rrf_k must be > 0"); return true; } diff --git a/core/src/solutions/operators/op_engine_backed.cpp b/core/src/solutions/operators/op_engine_backed.cpp index 794d6b636f..b4d44c7afc 100644 --- a/core/src/solutions/operators/op_engine_backed.cpp +++ b/core/src/solutions/operators/op_engine_backed.cpp @@ -96,6 +96,12 @@ const std::string* find_param(const runanywhere::v1::OperatorSpec& spec, const s return &it->second; } +// True when `key` is present with a non-empty value. +bool has_param(const runanywhere::v1::OperatorSpec& spec, const std::string& key) { + const std::string* value = find_param(spec, key); + return value != nullptr && !value->empty(); +} + int param_int_or(const runanywhere::v1::OperatorSpec& spec, const std::string& key, int fallback) { const std::string* value = find_param(spec, key); if (!value || value->empty()) @@ -160,12 +166,6 @@ bool param_float_strict(const runanywhere::v1::OperatorSpec& spec, const std::st return true; } -// True when `key` is present with a non-empty value. -bool has_param(const runanywhere::v1::OperatorSpec& spec, const std::string& key) { - const std::string* value = find_param(spec, key); - return value != nullptr && !value->empty(); -} - // Parse an unsigned 64-bit identifier (e.g. a host-provided session handle) // from `OperatorSpec.params`. Returns true and writes *out on success; // returns false when the key is missing, empty, or unparseable so the @@ -805,12 +805,12 @@ class RetrieveNode final : public OperatorNode { } else if (has_param(spec, "similarity_threshold") && (similarity_threshold_ < 0.0f || similarity_threshold_ > 1.0f)) { param_error_ = "retrieve param 'similarity_threshold' must be in 0.0...1.0"; - } else if (bm25_k1_ < 0.0f) { - param_error_ = "retrieve param 'bm25_k1' must be >= 0"; + } else if (has_param(spec, "bm25_k1") && bm25_k1_ <= 0.0f) { + param_error_ = "retrieve param 'bm25_k1' must be > 0"; } else if (has_param(spec, "bm25_b") && (bm25_b_ < 0.0f || bm25_b_ > 1.0f)) { param_error_ = "retrieve param 'bm25_b' must be in 0.0...1.0"; - } else if (rrf_k_ < 0) { - param_error_ = "retrieve param 'rrf_k' must be >= 0"; + } else if (has_param(spec, "rrf_k") && rrf_k_ <= 0) { + param_error_ = "retrieve param 'rrf_k' must be > 0"; } if (const std::string* p = find_param(spec, "system_prompt"); p) { system_prompt_ = *p; diff --git a/core/tests/test_advanced_modality_proto_abi.cpp b/core/tests/test_advanced_modality_proto_abi.cpp index 7f5593cabe..baaf796407 100644 --- a/core/tests/test_advanced_modality_proto_abi.cpp +++ b/core/tests/test_advanced_modality_proto_abi.cpp @@ -2287,6 +2287,26 @@ int test_rag_search_retrieval_only() { CHECK(rc == RAC_ERROR_INVALID_ARGUMENT, "rag.search rejects bm25_b outside [0, 1]"); rac_proto_buffer_free(&out); + // An explicit zero is the "unset" sentinel below the ABI, so it is rejected rather than + // silently replaced by the default. + runanywhere::v1::RAGSearchRequest zero_k1_search = tuned_search; + zero_k1_search.mutable_retrieval()->set_bm25_k1(0.0f); + std::vector zero_k1_bytes; + CHECK(serialize(zero_k1_search, &zero_k1_bytes), "bm25_k1 zero request serializes"); + rac_proto_buffer_init(&out); + rc = rac_rag_search_proto(full_session, zero_k1_bytes.data(), zero_k1_bytes.size(), &out); + CHECK(rc == RAC_ERROR_INVALID_ARGUMENT, "rag.search rejects an explicit bm25_k1 of 0"); + rac_proto_buffer_free(&out); + + runanywhere::v1::RAGSearchRequest zero_rrf_search = tuned_search; + zero_rrf_search.mutable_retrieval()->set_rrf_k(0); + std::vector zero_rrf_bytes; + CHECK(serialize(zero_rrf_search, &zero_rrf_bytes), "rrf_k zero request serializes"); + rac_proto_buffer_init(&out); + rc = rac_rag_search_proto(full_session, zero_rrf_bytes.data(), zero_rrf_bytes.size(), &out); + CHECK(rc == RAC_ERROR_INVALID_ARGUMENT, "rag.search rejects an explicit rrf_k of 0"); + rac_proto_buffer_free(&out); + runanywhere::v1::RAGSearchRequest inf_k1_search = tuned_search; inf_k1_search.mutable_retrieval()->set_bm25_k1(std::numeric_limits::infinity()); std::vector inf_k1_bytes; diff --git a/core/tests/test_solution_runner.cpp b/core/tests/test_solution_runner.cpp index fa52aa03d5..d01e36291a 100644 --- a/core/tests/test_solution_runner.cpp +++ b/core/tests/test_solution_runner.cpp @@ -1446,6 +1446,8 @@ TEST(retrieve_rejects_malformed_params) { {"bm25_k1", "inf", "fully-numeric"}, {"bm25_b", "100", "0.0...1.0"}, {"rrf_k", "-5", "rrf_k"}, + {"rrf_k", "0", "rrf_k"}, + {"bm25_k1", "0", "bm25_k1"}, {"bm25_b", "-0.5", "0.0...1.0"}, {"k", "0", "'k' must be >= 1"}, {"similarity_threshold", "1.5", "similarity_threshold"}, From d901a40e88119c5fff1a2ced5e9b335c925c6aa7 Mon Sep 17 00:00:00 2001 From: Aditya kumar singh <143548997+Adityakk9031@users.noreply.github.com> Date: Thu, 22 Oct 2026 11:40:16 +0530 Subject: [PATCH 4/5] fix(solutions): forward explicit solution-YAML BM25/RRF values by presence (#1076) --- core/src/solutions/config_loader.cpp | 65 +++++++++++++++++++---- core/src/solutions/solution_converter.cpp | 9 ++-- core/tests/test_solution_runner.cpp | 56 +++++++++++++++++++ idl/SCHEMA_LOCK | 2 +- idl/solutions.proto | 14 +++-- 5 files changed, 128 insertions(+), 18 deletions(-) diff --git a/core/src/solutions/config_loader.cpp b/core/src/solutions/config_loader.cpp index 4140467e09..09ae3c617a 100644 --- a/core/src/solutions/config_loader.cpp +++ b/core/src/solutions/config_loader.cpp @@ -33,7 +33,10 @@ #include #include +#include +#include #include +#include #include #include #include @@ -529,7 +532,39 @@ runanywhere::v1::VectorStore parse_vector_store(const std::string& s) { return VectorStore::VECTOR_STORE_UNSPECIFIED; } -void populate_rag(const YamlNode& node, runanywhere::v1::RAGConfig* cfg) { +// Strict numeric parse for the retrieval tuning keys: the whole scalar must be a finite +// number in the classic "C" locale. to_float()/to_int() fall back to 0 on garbage, which +// would now be forwarded as an explicit 0 (and bm25_b = 0 is a legal value). +bool parse_tuning_float(const std::string& s, float* out) { + std::istringstream stream(s); + stream.imbue(std::locale::classic()); + float value = 0.0f; + if (!(stream >> value) || stream.peek() != std::char_traits::eof() || + !std::isfinite(value)) { + return false; + } + *out = value; + return true; +} + +bool parse_tuning_int(const std::string& s, int32_t* out) { + std::istringstream stream(s); + stream.imbue(std::locale::classic()); + int32_t value = 0; + if (!(stream >> value) || stream.peek() != std::char_traits::eof()) { + return false; + } + *out = value; + return true; +} + +rac_result_t reject_tuning_value(const char* key, const std::string& scalar) { + rac_error_set_details( + ("rag." + std::string(key) + " must be a number, got '" + scalar + "'").c_str()); + return RAC_ERROR_INVALID_FORMAT; +} + +rac_result_t populate_rag(const YamlNode& node, runanywhere::v1::RAGConfig* cfg) { if (auto n = node.find("embed_model_id")) cfg->set_embed_model_id(n->scalar); if (auto n = node.find("rerank_model_id")) @@ -544,14 +579,27 @@ void populate_rag(const YamlNode& node, runanywhere::v1::RAGConfig* cfg) { cfg->set_retrieve_k(to_int(n->scalar)); if (auto n = node.find("rerank_top")) cfg->set_rerank_top(to_int(n->scalar)); - if (auto n = node.find("bm25_k1")) - cfg->set_bm25_k1(to_float(n->scalar)); - if (auto n = node.find("bm25_b")) - cfg->set_bm25_b(to_float(n->scalar)); - if (auto n = node.find("rrf_k")) - cfg->set_rrf_k(to_int(n->scalar)); + if (auto n = node.find("bm25_k1")) { + float value = 0.0f; + if (!parse_tuning_float(n->scalar, &value)) + return reject_tuning_value("bm25_k1", n->scalar); + cfg->set_bm25_k1(value); + } + if (auto n = node.find("bm25_b")) { + float value = 0.0f; + if (!parse_tuning_float(n->scalar, &value)) + return reject_tuning_value("bm25_b", n->scalar); + cfg->set_bm25_b(value); + } + if (auto n = node.find("rrf_k")) { + int32_t value = 0; + if (!parse_tuning_int(n->scalar, &value)) + return reject_tuning_value("rrf_k", n->scalar); + cfg->set_rrf_k(value); + } if (auto n = node.find("prompt_template")) cfg->set_prompt_template(n->scalar); + return RAC_SUCCESS; } rac_result_t populate_solution(const YamlNode& root, runanywhere::v1::SolutionConfig* cfg) { @@ -564,8 +612,7 @@ rac_result_t populate_solution(const YamlNode& root, runanywhere::v1::SolutionCo return RAC_SUCCESS; } if (auto n = root.find("rag"); n && n->kind == YamlNode::Kind::Mapping) { - populate_rag(*n, cfg->mutable_rag()); - return RAC_SUCCESS; + return populate_rag(*n, cfg->mutable_rag()); } rac_error_set_details("SolutionConfig YAML must declare one of: voice_agent, rag"); return RAC_ERROR_INVALID_FORMAT; diff --git a/core/src/solutions/solution_converter.cpp b/core/src/solutions/solution_converter.cpp index b2404fe3fd..9001b928e1 100644 --- a/core/src/solutions/solution_converter.cpp +++ b/core/src/solutions/solution_converter.cpp @@ -157,13 +157,16 @@ void expand_rag(const runanywhere::v1::RAGConfig& cfg, PipelineSpec* out) { (*retrieve->mutable_params())["vector_store"] = runanywhere::v1::VectorStore_Name(cfg.vector_store()); } - if (cfg.bm25_k1() > 0.0f) { + // Forward by presence, not value: a value test here turned an explicit 0 into "unset" + // (silently 1.2 / 60, and bm25_b = 0 dropped entirely) before the retrieve operator's + // range check could see the key. + if (cfg.has_bm25_k1()) { (*retrieve->mutable_params())["bm25_k1"] = format_float_param(cfg.bm25_k1()); } - if (cfg.bm25_b() > 0.0f) { + if (cfg.has_bm25_b()) { (*retrieve->mutable_params())["bm25_b"] = format_float_param(cfg.bm25_b()); } - if (cfg.rrf_k() > 0) { + if (cfg.has_rrf_k()) { (*retrieve->mutable_params())["rrf_k"] = std::to_string(cfg.rrf_k()); } (void)llm; diff --git a/core/tests/test_solution_runner.cpp b/core/tests/test_solution_runner.cpp index d01e36291a..b81653a46e 100644 --- a/core/tests/test_solution_runner.cpp +++ b/core/tests/test_solution_runner.cpp @@ -1189,6 +1189,61 @@ TEST(rag_retrieval_params_reach_the_retrieve_operator) { runner.wait(); } +// --------------------------------------------------------------------------- +// 10c. An explicit solution-YAML zero reaches the retrieve operator instead of +// collapsing into "unset" (issue #1076), and garbage fails at load time. +// --------------------------------------------------------------------------- +TEST(rag_yaml_explicit_zero_tuning_reaches_the_retrieve_operator) { + ScopedSolutionStandins standins; + + SolutionConfig cfg; + CHECK(rac::solutions::load_solution_from_yaml("rag:\n" + " embed_model_id: \"bge-small\"\n" + " llm_model_id: \"qwen3-4b\"\n" + " bm25_k1: 0\n" + " bm25_b: 0\n" + " rrf_k: 0\n", + &cfg) == RAC_SUCCESS); + CHECK(cfg.rag().has_bm25_k1() && cfg.rag().has_bm25_b() && cfg.rag().has_rrf_k()); + + SolutionRunner runner(cfg); + CHECK(runner.start() == RAC_SUCCESS); + const runanywhere::v1::OperatorSpec* retrieve = nullptr; + for (const auto& op : runner.spec().operators()) { + if (op.name() == "retrieve") + retrieve = &op; + } + CHECK(retrieve != nullptr); + if (retrieve != nullptr) { + // Present, so the operator's "> 0" guard sees bm25_k1/rrf_k and rejects them, + // and bm25_b = 0 (no length normalization) is honored instead of dropped. + const auto& params = retrieve->params(); + CHECK(params.count("bm25_k1") == 1 && params.at("bm25_k1") == "0"); + CHECK(params.count("bm25_b") == 1 && params.at("bm25_b") == "0"); + CHECK(params.count("rrf_k") == 1 && params.at("rrf_k") == "0"); + } + runner.close_input(); + runner.wait(); + + // An unset key still means "session default": nothing is forwarded. + SolutionConfig defaults_cfg; + CHECK(rac::solutions::load_solution_from_yaml("rag:\n" + " embed_model_id: \"bge-small\"\n" + " llm_model_id: \"qwen3-4b\"\n", + &defaults_cfg) == RAC_SUCCESS); + CHECK(!defaults_cfg.rag().has_bm25_k1() && !defaults_cfg.rag().has_rrf_k()); + + // Garbage used to become 0 through to_float(); it is a load error now. + SolutionConfig garbage_cfg; + rac_error_clear_details(); + CHECK(rac::solutions::load_solution_from_yaml("rag:\n" + " embed_model_id: \"bge-small\"\n" + " bm25_b: \"abc\"\n", + &garbage_cfg) == RAC_ERROR_INVALID_FORMAT); + const char* details = rac_error_get_details(); + CHECK(details != nullptr && std::string(details).find("rag.bm25_b") != std::string::npos); +} + // --------------------------------------------------------------------------- // 11. C ABI end-to-end: proto-bytes path. // --------------------------------------------------------------------------- @@ -1533,6 +1588,7 @@ int main() { run_test_voice_agent_explicit_zero_temperature_reaches_llm_operator(); run_test_rag_solution_compiles(); run_test_rag_retrieval_params_reach_the_retrieve_operator(); + run_test_rag_yaml_explicit_zero_tuning_reaches_the_retrieve_operator(); run_test_c_abi_proto_bytes_lifecycle(); run_test_voice_agent_barge_in_params_reach_the_vad_operator(); run_test_c_abi_yaml_solution_lifecycle(); diff --git a/idl/SCHEMA_LOCK b/idl/SCHEMA_LOCK index 5428238379..da9e3b2e42 100644 --- a/idl/SCHEMA_LOCK +++ b/idl/SCHEMA_LOCK @@ -14,5 +14,5 @@ # ============================================================================= IDL_VERSION=1.7.0 IDL_PROTO_COUNT=41 -IDL_SCHEMA_SHA256=227e5092537692d1ebcba2ab67284014b0a3eff0dc8a380474860f1c64eb9757 +IDL_SCHEMA_SHA256=23666cc651cc16dffaaffabcd8f4bb99c30c4c77fbc8ee8dec5370a996ab6604 IDL_PROTOC_VERSION=35.1 diff --git a/idl/solutions.proto b/idl/solutions.proto index fcd88f3de9..0b87f5cb4d 100644 --- a/idl/solutions.proto +++ b/idl/solutions.proto @@ -92,11 +92,15 @@ message RAGConfig { int32 rerank_top = 7; // BM25 term-saturation and length-normalization parameters. - float bm25_k1 = 8; - float bm25_b = 9; - - // Reciprocal-rank-fusion smoothing constant. - int32 rrf_k = 10; + // `optional` so an explicit YAML value -- including a 0 -- reaches the + // retrieve operator, which rejects bm25_k1 <= 0 and accepts bm25_b = 0 + // (no length normalization); unset keeps the session default. + optional float bm25_k1 = 8; + optional float bm25_b = 9; + + // Reciprocal-rank-fusion smoothing constant; an explicit value <= 0 is + // rejected by the retrieve operator, unset keeps the session default. + optional int32 rrf_k = 10; string prompt_template = 11; } From 4525cf7bf6ade7486f58c9e5aed3b6ede1b93fb2 Mon Sep 17 00:00:00 2001 From: Aditya kumar singh <143548997+Adityakk9031@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:37:26 +0530 Subject: [PATCH 5/5] fix(python): reject non-enum vector_store and non-numeric BM25/top_k in RAG options --- bindings/python/runanywhere/_rag_bridge.py | 22 +++++++++++++++------- bindings/python/tests/test_rag.py | 5 +++++ 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/bindings/python/runanywhere/_rag_bridge.py b/bindings/python/runanywhere/_rag_bridge.py index 32c27a4726..9a4c7a621d 100644 --- a/bindings/python/runanywhere/_rag_bridge.py +++ b/bindings/python/runanywhere/_rag_bridge.py @@ -117,12 +117,20 @@ def _int32_option(name: str, value: Any) -> int: def _vector_store_option(value: Any) -> int: """Return the wire value of a :class:`RagVectorStore`, rejecting anything else.""" - try: - return int(RagVectorStore(value)) - except ValueError: + if not isinstance(value, RagVectorStore): raise SDKException.invalid_input( f"RagRetrievalOptions.vector_store must be a RagVectorStore, got {value!r}" - ) from None + ) + return int(value) + + +def _float_option(name: str, value: Any) -> float: + """Return ``value`` as a float, rejecting bools and non-numbers.""" + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise SDKException.invalid_input( + f"RagRetrievalOptions.{name} must be a number, got {type(value).__name__}" + ) + return float(value) def build_query( @@ -135,13 +143,13 @@ def build_query( retrieval = options.retrieval if retrieval is not None: if retrieval.top_k is not None: - query.retrieval.top_k = int(retrieval.top_k) + query.retrieval.top_k = _int32_option("top_k", retrieval.top_k) if retrieval.similarity_threshold is not None: query.retrieval.score_threshold = retrieval.similarity_threshold if retrieval.bm25_k1 is not None: - query.retrieval.bm25_k1 = retrieval.bm25_k1 + query.retrieval.bm25_k1 = _float_option("bm25_k1", retrieval.bm25_k1) if retrieval.bm25_b is not None: - query.retrieval.bm25_b = retrieval.bm25_b + query.retrieval.bm25_b = _float_option("bm25_b", retrieval.bm25_b) if retrieval.rrf_k is not None: query.retrieval.rrf_k = _int32_option("rrf_k", retrieval.rrf_k) if retrieval.vector_store is not None: diff --git a/bindings/python/tests/test_rag.py b/bindings/python/tests/test_rag.py index e971e7cacf..b122fc441b 100644 --- a/bindings/python/tests/test_rag.py +++ b/bindings/python/tests/test_rag.py @@ -266,6 +266,11 @@ def test_query_threads_bm25_rrf_and_vector_store_into_the_proto(rag_core) -> Non RagRetrievalOptions(rrf_k=-0.5), # type: ignore[arg-type] RagRetrievalOptions(rrf_k=2**31), RagRetrievalOptions(vector_store=7), # type: ignore[arg-type] + RagRetrievalOptions(vector_store=True), # type: ignore[arg-type] + RagRetrievalOptions(vector_store=1), # type: ignore[arg-type] + RagRetrievalOptions(bm25_k1=True), # type: ignore[arg-type] + RagRetrievalOptions(bm25_b="0.5"), # type: ignore[arg-type] + RagRetrievalOptions(top_k=2.5), # type: ignore[arg-type] ], ) def test_query_rejects_malformed_retrieval_knobs_before_serializing(rag_core, retrieval) -> None: