Skip to content

Commit 55142e4

Browse files
committed
feat: add SearchAsync for vector, keyword, and hybrid search
Wraps POST /v1/search, which was previously unreachable from .NET without hand-rolling the HTTP call. spice.js is the only other SDK that exposes it. Only Text is required; Datasets, Limit, Where, AdditionalColumns and Keywords are optional. Supplying Keywords pre-filters the embedding column with a lexical search before the vector search, making the search hybrid. SearchMatch maps the runtime's wire format, including the `_score` field name and the objects the runtime omits when empty; those default to empty dictionaries so callers can read them without a null check. System.Text.Json was already resolved transitively via Apache.Arrow.Adbc; it is now referenced explicitly at the version that dependency already selects, since netstandard2.0 has no in-box System.Text.Json.
1 parent a6a0425 commit 55142e4

7 files changed

Lines changed: 528 additions & 0 deletions

File tree

README.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,50 @@ using var client = new SpiceClientBuilder()
215215
await client.RefreshDatasetAsync("my_dataset");
216216
```
217217

218+
#### Search
219+
220+
`SearchAsync` runs vector similarity, keyword, and hybrid search against datasets that have
221+
an embedding column and a loaded embedding model.
222+
223+
```csharp
224+
using Spice;
225+
using Spice.Search;
226+
227+
using var client = new SpiceClientBuilder().Build();
228+
229+
var response = await client.SearchAsync(new SearchRequest("tickets to Tokyo")
230+
{
231+
Datasets = new[] { "app_messages" },
232+
Limit = 3,
233+
});
234+
235+
Console.WriteLine($"{response.Results.Count} matches in {response.DurationMs}ms");
236+
foreach (var match in response.Results)
237+
{
238+
Console.WriteLine($"{match.Dataset} {match.Score}");
239+
}
240+
```
241+
242+
Only `Text` is required. `Datasets` restricts the search — leave it unset to search every
243+
dataset with an embedding column. `Limit` caps matches per dataset, `Where` applies an SQL
244+
predicate before the search, and `AdditionalColumns` names extra columns to return. Setting
245+
`Keywords` pre-filters the embedding column with a lexical search before the vector search
246+
runs, making the search hybrid:
247+
248+
```csharp
249+
var response = await client.SearchAsync(new SearchRequest("tickets to Tokyo")
250+
{
251+
Where = "city = 'Tokyo'",
252+
AdditionalColumns = new[] { "timestamp" },
253+
Keywords = new[] { "plane", "tickets" },
254+
});
255+
```
256+
257+
Each `SearchMatch` carries the `Dataset` it was found in, its similarity `Score`, the
258+
matched column values in `Matches`, the row's `PrimaryKey`, the columns requested via
259+
`AdditionalColumns` in `Data`, and any `Metadata`. The runtime omits the last three when
260+
empty; they default to empty dictionaries, so they can be read without a null check.
261+
218262
### Memory Management
219263

220264
The `SpiceClient` implements `IDisposable` and should be properly disposed to release network resources (gRPC channels, HTTP clients). Use the `using` statement or `using` declaration for automatic disposal:

Spice/Spice.csproj

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,4 +55,14 @@
5555
</AssemblyAttribute>
5656
</ItemGroup>
5757

58+
<!--
59+
System.Text.Json arrives transitively via Apache.Arrow.Adbc, but search
60+
serialization depends on it directly, so pin it explicitly. The version matches
61+
what Apache.Arrow.Adbc already resolves to; netstandard2.0 needs the package
62+
because, unlike net8.0+, it has no in-box System.Text.Json.
63+
-->
64+
<ItemGroup>
65+
<PackageReference Include="System.Text.Json" Version="9.0.9" />
66+
</ItemGroup>
67+
5868
</Project>

Spice/src/Http/ISpiceHttpClient.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
2020
SOFTWARE.
2121
*/
2222

23+
using Spice.Search;
24+
2325
namespace Spice.Http;
2426

2527
/// <summary>
@@ -44,4 +46,15 @@ public interface ISpiceHttpClient : IDisposable
4446
/// <exception cref="System.ArgumentException">Thrown when datasetName is null or empty</exception>
4547
/// <exception cref="System.Net.Http.HttpRequestException">Thrown when the HTTP request fails</exception>
4648
Task RefreshDatasetAsync(string datasetName);
49+
50+
/// <summary>
51+
/// Runs a vector, keyword, or hybrid search against datasets with an embedding column.
52+
/// </summary>
53+
/// <param name="request">The search to run</param>
54+
/// <param name="cancellationToken">Token to cancel the request</param>
55+
/// <returns>The matches, ordered by descending score</returns>
56+
/// <exception cref="System.ArgumentNullException">Thrown when request is null</exception>
57+
/// <exception cref="System.ArgumentException">Thrown when the search text is null or empty</exception>
58+
/// <exception cref="System.Net.Http.HttpRequestException">Thrown when the HTTP request fails</exception>
59+
Task<SearchResponse> SearchAsync(SearchRequest request, CancellationToken cancellationToken = default);
4760
}

Spice/src/Http/SpiceHttpClient.cs

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
2222

2323
using System.Net.Http.Headers;
2424
using System.Text;
25+
using System.Text.Json;
2526
using Spice.Auth;
27+
using Spice.Search;
2628

2729
namespace Spice.Http;
2830

@@ -136,6 +138,89 @@ public async Task RefreshDatasetAsync(string datasetName)
136138
response.EnsureSuccessStatusCode();
137139
}
138140

141+
/// <summary>
142+
/// Options used to serialize search requests and deserialize search responses.
143+
/// </summary>
144+
private static readonly JsonSerializerOptions SearchJsonOptions = new()
145+
{
146+
PropertyNamingPolicy = null,
147+
};
148+
149+
/// <summary>
150+
/// Runs a vector, keyword, or hybrid search against datasets with an embedding column.
151+
/// </summary>
152+
/// <param name="request">The search to run</param>
153+
/// <param name="cancellationToken">Token to cancel the request</param>
154+
/// <returns>The matches, ordered by descending score</returns>
155+
/// <exception cref="System.ArgumentNullException">Thrown when request is null</exception>
156+
/// <exception cref="System.ArgumentException">Thrown when the search text is null or empty</exception>
157+
/// <exception cref="System.Net.Http.HttpRequestException">Thrown when the HTTP request fails</exception>
158+
public async Task<SearchResponse> SearchAsync(SearchRequest request, CancellationToken cancellationToken = default)
159+
{
160+
#if NET8_0_OR_GREATER
161+
ObjectDisposedException.ThrowIf(_disposed, this);
162+
ArgumentNullException.ThrowIfNull(request);
163+
ArgumentException.ThrowIfNullOrWhiteSpace(request.Text, nameof(request));
164+
#else
165+
if (_disposed) throw new ObjectDisposedException(GetType().FullName);
166+
ThrowHelper.ThrowIfNull(request, nameof(request));
167+
ThrowHelper.ThrowIfNullOrWhiteSpace(request.Text, nameof(request));
168+
#endif
169+
170+
var url = $"{_httpAddress}/v1/search";
171+
var json = JsonSerializer.Serialize(request, SearchJsonOptions);
172+
173+
using var content = new StringContent(json, Encoding.UTF8, "application/json");
174+
using var response = await _httpClient.PostAsync(url, content, cancellationToken).ConfigureAwait(false);
175+
176+
#if NET8_0_OR_GREATER
177+
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
178+
#else
179+
// netstandard2.0 has no CancellationToken overload for ReadAsStringAsync.
180+
var body = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
181+
#endif
182+
183+
if (!response.IsSuccessStatusCode)
184+
{
185+
// Surface the runtime's own message — it names what the caller needs to fix.
186+
throw new HttpRequestException(
187+
$"Search failed with status {(int)response.StatusCode}: {ExtractErrorMessage(body)}");
188+
}
189+
190+
return JsonSerializer.Deserialize<SearchResponse>(body, SearchJsonOptions) ?? new SearchResponse();
191+
}
192+
193+
/// <summary>
194+
/// Pulls the runtime's error message out of a failed response body, falling back to
195+
/// the raw body when it is not the expected shape.
196+
/// </summary>
197+
/// <param name="body">The response body</param>
198+
/// <returns>The message to report</returns>
199+
private static string ExtractErrorMessage(string body)
200+
{
201+
if (string.IsNullOrWhiteSpace(body))
202+
{
203+
return "(no response body)";
204+
}
205+
206+
try
207+
{
208+
using var document = JsonDocument.Parse(body);
209+
if (document.RootElement.ValueKind == JsonValueKind.Object
210+
&& document.RootElement.TryGetProperty("error", out var error)
211+
&& error.ValueKind == JsonValueKind.String)
212+
{
213+
return error.GetString() ?? body;
214+
}
215+
}
216+
catch (JsonException)
217+
{
218+
// Not JSON — fall through and report the body verbatim.
219+
}
220+
221+
return body;
222+
}
223+
139224
/// <summary>
140225
/// Releases all resources used by the <see cref="SpiceHttpClient"/>.
141226
/// </summary>

Spice/src/Search/SearchTypes.cs

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
/*
2+
Copyright 2024 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+
using System.Text.Json;
24+
using System.Text.Json.Serialization;
25+
26+
namespace Spice.Search;
27+
28+
/// <summary>
29+
/// A search over datasets that have an embedding column and a loaded embedding model.
30+
/// </summary>
31+
/// <remarks>
32+
/// Only <see cref="Text"/> is required. Leave <see cref="Datasets"/> unset to search every
33+
/// dataset with an embedding column, and set <see cref="Keywords"/> to pre-filter the
34+
/// embedding column with a lexical search first, making the search hybrid.
35+
/// </remarks>
36+
public class SearchRequest
37+
{
38+
/// <summary>
39+
/// Creates a search request.
40+
/// </summary>
41+
/// <param name="text">The query to find similar documents for</param>
42+
public SearchRequest(string text)
43+
{
44+
Text = text;
45+
}
46+
47+
/// <summary>
48+
/// The query to find similar documents for.
49+
/// </summary>
50+
[JsonPropertyName("text")]
51+
public string Text { get; set; }
52+
53+
/// <summary>
54+
/// Restricts the search to these datasets. Null searches every dataset with an
55+
/// embedding column.
56+
/// </summary>
57+
[JsonPropertyName("datasets")]
58+
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
59+
public IReadOnlyList<string>? Datasets { get; set; }
60+
61+
/// <summary>
62+
/// Maximum matches to return per dataset. Null uses the runtime's default.
63+
/// </summary>
64+
[JsonPropertyName("limit")]
65+
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
66+
public int? Limit { get; set; }
67+
68+
/// <summary>
69+
/// An SQL predicate applied before the search, without the WHERE keyword —
70+
/// for example <c>city = 'Tokyo'</c>.
71+
/// </summary>
72+
[JsonPropertyName("where")]
73+
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
74+
public string? Where { get; set; }
75+
76+
/// <summary>
77+
/// Extra dataset columns to return. A column that is part of the primary key is
78+
/// returned in <see cref="SearchMatch.PrimaryKey"/> rather than <see cref="SearchMatch.Data"/>.
79+
/// </summary>
80+
[JsonPropertyName("additional_columns")]
81+
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
82+
public IReadOnlyList<string>? AdditionalColumns { get; set; }
83+
84+
/// <summary>
85+
/// Pre-filters the embedding column with a lexical search before the vector search
86+
/// runs, making the search hybrid.
87+
/// </summary>
88+
[JsonPropertyName("keywords")]
89+
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
90+
public IReadOnlyList<string>? Keywords { get; set; }
91+
}
92+
93+
/// <summary>
94+
/// A single document matched by a search.
95+
/// </summary>
96+
/// <remarks>
97+
/// The runtime omits <see cref="Data"/>, <see cref="PrimaryKey"/> and <see cref="Metadata"/>
98+
/// when they are empty; they default to empty dictionaries so they can be read without a
99+
/// null check.
100+
/// </remarks>
101+
public class SearchMatch
102+
{
103+
/// <summary>
104+
/// The dataset the match was found in.
105+
/// </summary>
106+
[JsonPropertyName("dataset")]
107+
public string Dataset { get; set; } = string.Empty;
108+
109+
/// <summary>
110+
/// Similarity of the match to the query text. Higher is closer.
111+
/// </summary>
112+
/// <remarks>The runtime serializes this as <c>_score</c>.</remarks>
113+
[JsonPropertyName("_score")]
114+
public double Score { get; set; }
115+
116+
/// <summary>
117+
/// The matched values of each searched column.
118+
/// </summary>
119+
[JsonPropertyName("matches")]
120+
public IReadOnlyDictionary<string, IReadOnlyList<JsonElement>> Matches { get; set; }
121+
= new Dictionary<string, IReadOnlyList<JsonElement>>();
122+
123+
/// <summary>
124+
/// Primary key identifying the matched row, if the dataset declares one.
125+
/// </summary>
126+
[JsonPropertyName("primary_key")]
127+
public IReadOnlyDictionary<string, JsonElement> PrimaryKey { get; set; }
128+
= new Dictionary<string, JsonElement>();
129+
130+
/// <summary>
131+
/// Columns requested via <see cref="SearchRequest.AdditionalColumns"/>.
132+
/// </summary>
133+
[JsonPropertyName("data")]
134+
public IReadOnlyDictionary<string, JsonElement> Data { get; set; }
135+
= new Dictionary<string, JsonElement>();
136+
137+
/// <summary>
138+
/// Any additional metadata the runtime attached to the match.
139+
/// </summary>
140+
[JsonPropertyName("metadata")]
141+
public IReadOnlyDictionary<string, JsonElement> Metadata { get; set; }
142+
= new Dictionary<string, JsonElement>();
143+
}
144+
145+
/// <summary>
146+
/// The result of a search.
147+
/// </summary>
148+
public class SearchResponse
149+
{
150+
/// <summary>
151+
/// Matches, ordered by descending score.
152+
/// </summary>
153+
[JsonPropertyName("results")]
154+
public IReadOnlyList<SearchMatch> Results { get; set; } = new List<SearchMatch>();
155+
156+
/// <summary>
157+
/// How long the runtime took to run the search.
158+
/// </summary>
159+
[JsonPropertyName("duration_ms")]
160+
public long DurationMs { get; set; }
161+
}

Spice/src/SpiceClient.cs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
2626
using Spice.Config;
2727
using Spice.Flight;
2828
using Spice.Http;
29+
using Spice.Search;
2930

3031
namespace Spice;
3132

@@ -189,6 +190,33 @@ public Task RefreshDatasetAsync(string datasetName)
189190
return HttpClient.RefreshDatasetAsync(datasetName);
190191
}
191192

193+
/// <summary>
194+
/// Runs a vector, keyword, or hybrid search.
195+
/// </summary>
196+
/// <remarks>
197+
/// Searches datasets that have an embedding column and a loaded embedding model,
198+
/// returning the documents most similar to the request text. Setting
199+
/// <see cref="SearchRequest.Keywords"/> pre-filters the embedding column with a
200+
/// lexical search first, making the search hybrid.
201+
/// </remarks>
202+
/// <param name="request">The search to run</param>
203+
/// <param name="cancellationToken">Token to cancel the request</param>
204+
/// <returns>The matches, ordered by descending score</returns>
205+
/// <exception cref="System.ArgumentNullException">Thrown when request is null</exception>
206+
/// <exception cref="System.ArgumentException">Thrown when the search text is null or empty</exception>
207+
/// <exception cref="System.Net.Http.HttpRequestException">Thrown when the HTTP request fails</exception>
208+
public Task<SearchResponse> SearchAsync(SearchRequest request, CancellationToken cancellationToken = default)
209+
{
210+
#if NET8_0_OR_GREATER
211+
ObjectDisposedException.ThrowIf(_disposed, this);
212+
#else
213+
if (_disposed) throw new ObjectDisposedException(GetType().FullName);
214+
#endif
215+
if (HttpClient == null) throw new InvalidOperationException("HttpClient not initialized");
216+
217+
return HttpClient.SearchAsync(request, cancellationToken);
218+
}
219+
192220
private bool _disposed;
193221

194222
/// <summary>

0 commit comments

Comments
 (0)