Skip to content

Commit cbec170

Browse files
authored
feat: add search() for the runtime's /v1/search endpoint (#54)
* feat: add search() for the runtime's /v1/search endpoint Exposes vector, keyword, and hybrid search from the Java SDK. Previously only spice.js could reach /v1/search; Java users had to hand-roll the HTTP call. SearchResponse mirrors the runtime's wire shape (per-match score, matches, primary key, additional data, metadata), with map fields returned empty rather than null when the runtime omits them. * fix: give the authenticated SearchTest case a real Flight endpoint withApiKey() makes SpiceClient's constructor perform a real Flight handshake, but the API-key test only stood up the HTTP mock server, not a Flight server. The handshake against a dead default Flight address failed unpredictably by platform (reliable on Windows CI, intermittent on Linux/macOS). Start a TestFlightSqlServer with matching credentials for that case. * fix: address review feedback and SpotBugs findings on search() - Resolve the /v1/search URI against the base address instead of concatenating, so a trailing-slash httpAddress doesn't miss the route. - Accept both the current array-valued "matches" shape and the scalar shape older (pre-2.0) runtimes send, instead of failing to parse it. - Defensively copy SearchRequest's list fields on the way in and return unmodifiable views on the way out (SpotBugs EI_EXPOSE_REP/REP2). - Fix two docs links pointing at the inactive docs.spiceai.org host.
1 parent a52c89a commit cbec170

6 files changed

Lines changed: 764 additions & 0 deletions

File tree

README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,29 @@ for (ConnectionDetails connection : client.runtimeStatus()) {
415415
`REFRESHING`, `SHUTTING_DOWN`, or `NOT_LOADED`. A status a newer runtime introduces maps to
416416
`UNKNOWN` rather than failing; `getRawStatus()` returns it verbatim.
417417

418+
#### Search
419+
420+
Use `search()` to find documents similar to a piece of text via the runtime's
421+
`/v1/search` endpoint. This runs against datasets with an embedding column and a
422+
loaded embedding model — see the
423+
[search and retrieval docs](https://docs.spice.ai/features/search-and-retrieval) for
424+
how to configure them. Supplying `withKeywords(...)` adds a lexical pass, which the
425+
runtime blends with the vector scores into a hybrid ranking.
426+
427+
```java
428+
SpiceClient client = SpiceClient.builder()
429+
..
430+
.build();
431+
432+
SearchResponse response = client.search(new SearchRequest("food safety violations")
433+
.withDatasets(Arrays.asList("restaurant_inspections"))
434+
.withLimit(5));
435+
436+
for (SearchMatch match : response.getResults()) {
437+
System.out.printf("%s (score=%.3f)%n", match.getDataset(), match.getScore());
438+
}
439+
```
440+
418441
### Logging
419442

420443
The SDK uses SLF4J for logging, allowing you to plug in your preferred logging implementation (Logback, Log4j2, java.util.logging, etc.).
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
/*
2+
Copyright 2026 The Spice.ai OSS Authors
3+
4+
Permission is hereby granted, free of charge, to any person obtaining a copy
5+
of this software and associated documentation files (the "Software"), to deal
6+
in the Software without restriction, including without limitation the rights
7+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8+
copies of the Software, and to permit persons to whom the Software is
9+
furnished to do so, subject to the following conditions:
10+
11+
The above copyright notice and this permission notice shall be included in all
12+
copies or substantial portions of the Software.
13+
14+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
20+
SOFTWARE.
21+
*/
22+
23+
package ai.spice;
24+
25+
import java.lang.reflect.Type;
26+
import java.util.ArrayList;
27+
import java.util.Collections;
28+
import java.util.LinkedHashMap;
29+
import java.util.List;
30+
import java.util.Map;
31+
32+
import com.google.gson.JsonDeserializationContext;
33+
import com.google.gson.JsonDeserializer;
34+
import com.google.gson.JsonElement;
35+
import com.google.gson.JsonParseException;
36+
import com.google.gson.annotations.JsonAdapter;
37+
import com.google.gson.annotations.SerializedName;
38+
39+
/**
40+
* A single document matched by {@link SpiceClient#search(SearchRequest)}.
41+
*
42+
* <p>
43+
* The runtime omits {@code primary_key}, {@code data}, and {@code metadata}
44+
* from a match that has none. Unlike that wire shape, the getters below
45+
* return an empty map rather than {@code null} in that case, so callers never
46+
* need a null check.
47+
*/
48+
public class SearchMatch {
49+
50+
@SerializedName("dataset")
51+
private String dataset;
52+
53+
@SerializedName("_score")
54+
private double score;
55+
56+
// Spice 2.0 changed each entry to always be an array (a column can
57+
// contribute several chunks to a match); before that, a single match
58+
// serialized as a bare scalar. This API isn't documented as 2.0-only, so
59+
// accept both shapes rather than failing on the older one.
60+
@SerializedName("matches")
61+
@JsonAdapter(MatchesDeserializer.class)
62+
private Map<String, List<Object>> matches;
63+
64+
@SerializedName("primary_key")
65+
private Map<String, Object> primaryKey;
66+
67+
@SerializedName("data")
68+
private Map<String, Object> data;
69+
70+
@SerializedName("metadata")
71+
private Map<String, Object> metadata;
72+
73+
/**
74+
* The dataset the match was found in.
75+
*
76+
* @return the dataset name
77+
*/
78+
public String getDataset() {
79+
return this.dataset;
80+
}
81+
82+
/**
83+
* The match's similarity to the query. Higher is more similar.
84+
*
85+
* @return the score
86+
*/
87+
public double getScore() {
88+
return this.score;
89+
}
90+
91+
/**
92+
* The matched values keyed by the column they came from. Each value is a
93+
* list because one column can contribute several chunks to a single
94+
* match.
95+
*
96+
* @return the matched values, or an empty map if the runtime returned
97+
* none
98+
*/
99+
public Map<String, List<Object>> getMatches() {
100+
return this.matches == null ? Collections.emptyMap() : this.matches;
101+
}
102+
103+
/**
104+
* Identifies the matched row. Empty when the dataset declares no primary
105+
* key.
106+
*
107+
* @return the primary key columns, or an empty map if the dataset has
108+
* none
109+
*/
110+
public Map<String, Object> getPrimaryKey() {
111+
return this.primaryKey == null ? Collections.emptyMap() : this.primaryKey;
112+
}
113+
114+
/**
115+
* Any {@code additionalColumns} that were requested. Empty when none were
116+
* requested.
117+
*
118+
* @return the additional column values, or an empty map if none were
119+
* requested
120+
*/
121+
public Map<String, Object> getData() {
122+
return this.data == null ? Collections.emptyMap() : this.data;
123+
}
124+
125+
/**
126+
* Extra per-match metadata the runtime attached. Empty when it attached
127+
* none.
128+
*
129+
* @return the metadata, or an empty map if the runtime attached none
130+
*/
131+
public Map<String, Object> getMetadata() {
132+
return this.metadata == null ? Collections.emptyMap() : this.metadata;
133+
}
134+
135+
/**
136+
* Normalizes a {@code matches} entry to a list regardless of whether the
137+
* runtime serialized it as an array (Spice 2.0+) or a bare scalar (older
138+
* runtimes).
139+
*/
140+
static final class MatchesDeserializer implements JsonDeserializer<Map<String, List<Object>>> {
141+
@Override
142+
public Map<String, List<Object>> deserialize(JsonElement json, Type typeOfT,
143+
JsonDeserializationContext context) throws JsonParseException {
144+
if (json == null || json.isJsonNull()) {
145+
return null;
146+
}
147+
if (!json.isJsonObject()) {
148+
throw new JsonParseException("Expected a JSON object for \"matches\", got: " + json);
149+
}
150+
Map<String, List<Object>> result = new LinkedHashMap<>();
151+
for (Map.Entry<String, JsonElement> entry : json.getAsJsonObject().entrySet()) {
152+
JsonElement value = entry.getValue();
153+
if (value.isJsonArray()) {
154+
List<Object> values = new ArrayList<>();
155+
for (JsonElement element : value.getAsJsonArray()) {
156+
values.add(context.deserialize(element, Object.class));
157+
}
158+
result.put(entry.getKey(), values);
159+
} else {
160+
result.put(entry.getKey(), Collections.singletonList(context.deserialize(value, Object.class)));
161+
}
162+
}
163+
return result;
164+
}
165+
}
166+
}
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
/*
2+
Copyright 2026 The Spice.ai OSS Authors
3+
4+
Permission is hereby granted, free of charge, to any person obtaining a copy
5+
of this software and associated documentation files (the "Software"), to deal
6+
in the Software without restriction, including without limitation the rights
7+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8+
copies of the Software, and to permit persons to whom the Software is
9+
furnished to do so, subject to the following conditions:
10+
11+
The above copyright notice and this permission notice shall be included in all
12+
copies or substantial portions of the Software.
13+
14+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
20+
SOFTWARE.
21+
*/
22+
23+
package ai.spice;
24+
25+
import java.util.ArrayList;
26+
import java.util.Collections;
27+
import java.util.List;
28+
29+
import com.google.gson.annotations.SerializedName;
30+
31+
/**
32+
* A search against the runtime's {@code /v1/search} endpoint.
33+
*
34+
* <p>
35+
* Only {@code text} is required. Supplying {@code keywords} adds a lexical
36+
* pass, which the runtime combines with the vector scores into a single
37+
* hybrid ranking.
38+
*/
39+
public class SearchRequest {
40+
41+
@SerializedName("text")
42+
private final String text;
43+
44+
@SerializedName("datasets")
45+
private List<String> datasets;
46+
47+
@SerializedName("limit")
48+
private Integer limit;
49+
50+
@SerializedName("where")
51+
private String where;
52+
53+
@SerializedName("additional_columns")
54+
private List<String> additionalColumns;
55+
56+
@SerializedName("keywords")
57+
private List<String> keywords;
58+
59+
/**
60+
* Creates a search request.
61+
*
62+
* @param text the text to find similar documents for
63+
*/
64+
public SearchRequest(String text) {
65+
this.text = text;
66+
}
67+
68+
/**
69+
* Restricts the search to the named datasets. When unset, the runtime
70+
* searches every searchable dataset.
71+
*
72+
* @param datasets the dataset names
73+
* @return this request
74+
*/
75+
public SearchRequest withDatasets(List<String> datasets) {
76+
this.datasets = datasets == null ? null : new ArrayList<>(datasets);
77+
return this;
78+
}
79+
80+
/**
81+
* Caps the number of matches returned per dataset.
82+
*
83+
* @param limit the maximum number of matches, must be greater than 0
84+
* @return this request
85+
*/
86+
public SearchRequest withLimit(int limit) {
87+
this.limit = limit;
88+
return this;
89+
}
90+
91+
/**
92+
* A SQL predicate filtering candidate rows, without the leading
93+
* {@code WHERE} — for example {@code "user_id = 42"}.
94+
*
95+
* @param where the predicate
96+
* @return this request
97+
*/
98+
public SearchRequest withWhere(String where) {
99+
this.where = where;
100+
return this;
101+
}
102+
103+
/**
104+
* Names extra columns to return with each match. A primary key column is
105+
* returned in {@link SearchMatch#getPrimaryKey()}, the rest in
106+
* {@link SearchMatch#getData()}.
107+
*
108+
* @param additionalColumns the column names
109+
* @return this request
110+
*/
111+
public SearchRequest withAdditionalColumns(List<String> additionalColumns) {
112+
this.additionalColumns = additionalColumns == null ? null : new ArrayList<>(additionalColumns);
113+
return this;
114+
}
115+
116+
/**
117+
* Drives the lexical pass of a hybrid search.
118+
*
119+
* @param keywords the keywords to match lexically
120+
* @return this request
121+
*/
122+
public SearchRequest withKeywords(List<String> keywords) {
123+
this.keywords = keywords == null ? null : new ArrayList<>(keywords);
124+
return this;
125+
}
126+
127+
public String getText() {
128+
return this.text;
129+
}
130+
131+
public List<String> getDatasets() {
132+
return this.datasets == null ? null : Collections.unmodifiableList(this.datasets);
133+
}
134+
135+
public Integer getLimit() {
136+
return this.limit;
137+
}
138+
139+
public String getWhere() {
140+
return this.where;
141+
}
142+
143+
public List<String> getAdditionalColumns() {
144+
return this.additionalColumns == null ? null : Collections.unmodifiableList(this.additionalColumns);
145+
}
146+
147+
public List<String> getKeywords() {
148+
return this.keywords == null ? null : Collections.unmodifiableList(this.keywords);
149+
}
150+
}

0 commit comments

Comments
 (0)