feat: add search for vector, keyword, and hybrid search - #82
Conversation
Wraps POST /v1/search, which was previously unreachable from Rust without hand-rolling the HTTP call. spice.js is the only other SDK that exposes it. SearchRequest follows the crate's builder convention — only the query text is required, and with_keywords pre-filters the embedding column with a lexical search before the vector search, making the search hybrid. SearchMatch deserializes the runtime's wire format, including the `_score` field name and the objects the runtime omits when empty; those decode to empty maps so callers can read them without a guard.
There was a problem hiding this comment.
Pull request overview
Adds first-class Rust SDK support for the Spice runtime’s POST /v1/search endpoint by introducing typed request/response models and a new SpiceClient::search method, enabling vector, keyword, and hybrid search over embedding-enabled datasets.
Changes:
- Introduces
SearchRequest,SearchResponse,SearchMatch, andSearchErrorwith serde wire-format handling (e.g.,where,_score, omitted empty maps). - Adds
SpiceClient::searchand the underlying HTTP call inQueryHttpClient. - Exports the new search types publicly and documents usage in the README (plus adds wiremock/serde tests).
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| src/search.rs | New public search request/response/error types, builder API, and serde tests for wire-format compatibility. |
| src/query.rs | Adds HTTP client method to POST /v1/search and decode the response (or surface runtime errors). |
| src/lib.rs | Wires in the new module and re-exports the public search API types. |
| src/client.rs | Adds SpiceClient::search wrapper with validation, docs, and wiremock-backed client tests. |
| README.md | Documents the new search() capability with runnable examples (doctested). |
Comments suppressed due to low confidence (2)
src/search.rs:109
with_additional_columnsserializes an empty iterator as"additional_columns": []because it setsSome(empty_vec). Since empty lists typically mean “no extra columns” (i.e., omit the field), treating empty asNoneavoids sending a value that may have different semantics than omission.
pub fn with_additional_columns<I, S>(mut self, columns: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.additional_columns = Some(columns.into_iter().map(Into::into).collect());
self
}
src/search.rs:119
with_keywordscurrently serializes an empty iterator as"keywords": [](because it setsSome(empty_vec)). If empty keywords should behave the same as “no keywords”, it’s clearer to omit the field by treating empty asNone.
pub fn with_keywords<I, S>(mut self, keywords: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.keywords = Some(keywords.into_iter().map(Into::into).collect());
self
}
…gistry `spice add spiceai/quickstart` makes every run depend on the Spicepod registry, which is currently returning a body the CLI cannot unpack: Invalid argument: Failed to extract Spicepod archive: invalid Zip archive: Could not find EOCD Declaring the same dataset inline removes the dependency. Covers both the Unix step (heredoc) and the Windows step (PowerShell here-string). Verified locally: taxi_trips loads and is queryable within seconds. Mirrors spicepy#176.
…' into merge/ci-fix
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (2)
src/search.rs:44
SearchRequestderivesDefault, but an emptytextis invalid (andSpiceClient::searchexplicitly rejects it). ExposingSearchRequest::default()makes it easy for callers to construct an invalid request and contradicts the docs that implySearchRequest::new(text)is the required entrypoint. Consider removing theDefaultderive (and constructing fields explicitly innew) to avoid exposing an invalid default instance.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
pub struct SearchRequest {
/// The query to find similar documents for.
pub text: String,
src/query.rs:716
unwrap_or_default()onresponse.text().awaitsilently drops body-read failures, which can make diagnosing non-200 responses harder (you'll report an emptyresponse_body). Consider preserving the error context in theresponse_bodystring when the body can't be read.
let response_body = response.text().await.unwrap_or_default();
Review asked whether with_datasets(empty) should coerce to None. It should not: an empty list asks for a search over no datasets and the runtime says so, while None widens the search to every dataset with an embedding column. A caller whose list came out empty is better served by the error than by a silent fan-out, and gospice, spicepy and spice-dotnet all send an explicit empty list too. Documents the distinction on the field and pins it with a test.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
src/client.rs:600
SpiceClient::searchonly checksrequest.text.is_empty(), so a whitespace-only query (e.g. " ") is treated as valid and will be sent to the runtime. Given the API docs/error variant (EmptyText) imply “no query text”, trimming before validation avoids an unnecessary network round trip and keeps client-side validation consistent with intent.
if request.text.is_empty() {
return Err(SearchError::EmptyText);
}
|
This PR is One commit is responsible. Exactly one thing has landed on It is mechanical — the two sides do not disagree about anything. They touch the same three files in different places:
The genuine textual collision is in The one thing worth checking, which also turns out to be clear: #85 made Not resolving it myself, deliberately. I have a competing PR — #84 adds the same feature from a fork, and I flagged there earlier today that this PR implements it and was opened first. Which of the two lands is your call, not something I should pre-empt by pushing to this branch. Happy to do the merge if you'd rather I did; it should be a short one. Worth noting for sequencing: this PR and #83 are the two that unblock the SDK search work more broadly — the fork-based PRs cannot go green until the CI-side fixes land. |
|
Closing as superseded — search landed via #84 and the CI fix via #83. Trunk already covers everything here:
No remaining functionality to merge from this branch. |
What
Adds
SpiceClient::search, wrapping the runtime'sPOST /v1/search— vector similarity, keyword, and hybrid search over datasets with an embedding column.SearchRequestfollows the crate's existing builder convention (mut self→Self), so only the query text is required.with_keywordspre-filters the embedding column with a lexical search before the vector search runs, making the search hybrid.New public exports:
SearchRequest,SearchResponse,SearchMatch,SearchError.Why
/v1/searchworks on a defaultspice runand is a distinctive Spice capability, but Rust users had no way to reach it short of hand-rolling the HTTP call. spice.js is currently the only SDK that exposes it.Two wire-format details the types handle so callers don't have to:
_score, notscore, and the request's SQL predicate iswhere, notwhere_condmatches,data,primary_keyandmetadataare omitted entirely when empty, so they deserialize to empty maps — readable without a guardErrors go through a
SearchErrorenum consistent withDatasetErrorandQueryError, and surface the runtime's own message on a rejected search rather than a raw transport error.Part of aligning search support across the SDKs.
Verification
cargo buildcargo test --lib— 197 passed, including 6 serde tests insearch.rsand 4 wiremock-backed client tests covering request encoding, response decoding, empty-text rejection, and runtime error surfacingcargo test --doc— 29 passed, including the new README examples (the crate doctests its README viainclude_str!)cargo fmt --all --checkcargo clippy --all-features— no new warningsspice run: the search request reaches/v1/search, and afailure surfaces the runtime's own message (
"Search cannot be run on <dataset> because it has no embeddings or full text search indexes.") rather than a bare status codeembedding column and a loaded embedding model, which this environment has no credentials for. The 24
tests/client_test.rsfailures in a fullcargo testare all refused Flight connections and are unrelated to this change.