Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(),
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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` (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` (0 is rejected). Null uses the session default. */
val rrfK: Int? = null,
val vectorStore: RagVectorStore? = null,
)

/** Per-query knobs for `RagSession.query`/`queryStream`. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 =
Expand Down
2 changes: 2 additions & 0 deletions bindings/python/runanywhere/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@
RagConfig,
RagQueryOptions,
RagRetrievalOptions,
RagVectorStore,
ReasoningMode,
ReasoningOptions,
SegmentationOptions,
Expand Down Expand Up @@ -445,6 +446,7 @@ def capabilities() -> SDKCapabilities:
"RagConfig",
"RagQueryOptions",
"RagRetrievalOptions",
"RagVectorStore",
"ReasoningMode",
"ReasoningOptions",
"SegmentationOptions",
Expand Down
45 changes: 43 additions & 2 deletions bindings/python/runanywhere/_rag_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -100,6 +100,39 @@ 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."""
if not isinstance(value, RagVectorStore):
raise SDKException.invalid_input(
f"RagRetrievalOptions.vector_store must be a RagVectorStore, got {value!r}"
)
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(
question: str,
options: Optional[RagQueryOptions],
Expand All @@ -110,9 +143,17 @@ 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 = _float_option("bm25_k1", retrieval.bm25_k1)
if retrieval.bm25_b is not None:
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:
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
Expand Down
14 changes: 14 additions & 0 deletions bindings/python/runanywhere/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"RagConfig",
"RagQueryOptions",
"RagRetrievalOptions",
"RagVectorStore",
"ReasoningMode",
"ReasoningOptions",
"SegmentationOptions",
Expand Down Expand Up @@ -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`` (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`` (0 is rejected). ``None`` uses the session default.
rrf_k: Optional[int] = None
vector_store: Optional[RagVectorStore] = None


@dataclass
Expand Down
38 changes: 38 additions & 0 deletions bindings/python/tests/test_rag.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
RagQueryOptions,
RagResult,
RagRetrievalOptions,
RagVectorStore,
RagSession,
ReasoningMode,
ReasoningOptions,
Expand Down Expand Up @@ -241,6 +242,43 @@ 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]
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:
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."))
Expand Down
23 changes: 22 additions & 1 deletion bindings/swift/Sources/RunAnywhere/Public/API/Handles.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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` (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` (0 is rejected). 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
}
}

Expand Down
27 changes: 23 additions & 4 deletions bindings/swift/Sources/RunAnywhere/Public/API/Rag/RagSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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

Expand Down Expand Up @@ -209,22 +209,41 @@ 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()
}
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(
Expand Down
12 changes: 11 additions & 1 deletion bindings/web/packages/core/src/Public/API/Namespaces/rag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The Web API accepts any number for the new int32 rrfK field and forwards it without checking that it is an integer within the int32 range. Validate Number.isInteger(rrfK) and the int32 bounds before assigning it, otherwise fractional or oversized caller input can be coerced during protobuf serialization instead of being rejected.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At bindings/web/packages/core/src/Public/API/Namespaces/rag.ts, line 82:

<comment>The Web API accepts any `number` for the new `int32` `rrfK` field and forwards it without checking that it is an integer within the int32 range. Validate `Number.isInteger(rrfK)` and the int32 bounds before assigning it, otherwise fractional or oversized caller input can be coerced during protobuf serialization instead of being rejected.</comment>

<file context>
@@ -66,11 +66,21 @@ function isRagQueryOptions(
         enableMultiQuery: false,
+        bm25K1: options.retrieval?.bm25K1,
+        bm25B: options.retrieval?.bm25B,
+        rrfK: options.retrieval?.rrfK,
+        vectorStore: vectorStore === 'usearch' ? VectorStore.VECTOR_STORE_USEARCH : undefined,
       },
</file context>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: rrfK must be an int32 integer, now checked before the call.

vectorStore: vectorStore === 'usearch' ? VectorStore.VECTOR_STORE_USEARCH : undefined,
},
generation: options.generation ? toProtoLlmOptions(options.generation) : undefined,
};
Expand Down
Loading
Loading