Skip to content

Commit 3bbffb4

Browse files
authored
feat: add SearchAsync for vector, keyword, and hybrid search (#22)
* 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. * fix: surface why authentication failed instead of a trailer error AuthenticateAsync passed stream.GetTrailers() as an argument, so trailers were read eagerly — before the handshake had completed. gRPC only makes trailers available once a call finishes, so any handshake that fails throws System.InvalidOperationException: Can't get the call trailers because the call has not completed successfully. which replaces the gRPC status describing the actual problem. Every integration test in CI reports this, and it says nothing about what to fix. Now the token is read from the response headers first, trailers are consulted only if the headers carry none, and a failure to read them is caught rather than propagated. The resulting SpiceException names the likely cause and carries the originating RpcException as InnerException. Reproduced against a TLS endpoint that is not a Flight service: before: InvalidOperationException: Can't get the call trailers ... after: SpiceException: Failed to authenticate: the runtime returned no authorization token. Check that the API key is valid for this endpoint. * fix: scope the System.Text.Json reference to netstandard2.0, name the failing member Two issues from review: - System.Text.Json was referenced unconditionally, pushing a dependency constraint onto net8.0+ consumers that already have it in-box. Now conditioned on netstandard2.0, which is the target that actually needs it. netstandard2.0 still resolves 9.0.9, so there is no NU1605 downgrade. - Argument validation reported nameof(request) when it was request.Text that was empty, pointing callers at the wrong member.
1 parent a6a0425 commit 3bbffb4

9 files changed

Lines changed: 604 additions & 4 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+
netstandard2.0 has no in-box System.Text.Json, so it needs the package. net8.0+
60+
ship it in the framework, and referencing it there would push an unnecessary
61+
dependency constraint onto consumers. The version matches what
62+
Apache.Arrow.Adbc already resolves to, so netstandard2.0 sees no downgrade.
63+
-->
64+
<ItemGroup Condition="'$(TargetFramework)' == 'netstandard2.0'">
65+
<PackageReference Include="System.Text.Json" Version="9.0.9" />
66+
</ItemGroup>
67+
5868
</Project>

Spice/src/Errors/SpiceException.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,4 +36,10 @@ internal SpiceException(SpiceStatus status, string message) : base(message)
3636
{
3737
Status = status;
3838
}
39+
40+
internal SpiceException(SpiceStatus status, string message, Exception? innerException)
41+
: base(message, innerException)
42+
{
43+
Status = status;
44+
}
3945
}

Spice/src/Flight/SpiceFlightClient.cs

Lines changed: 70 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -166,17 +166,83 @@ private async Task AuthenticateAsync()
166166
{
167167
var stream = _flightClient.Handshake();
168168

169-
var headers = await stream.ResponseHeadersAsync.ConfigureAwait(false);
170-
var token = GetAuthToken(headers, stream.GetTrailers());
171-
169+
Metadata headers;
170+
try
171+
{
172+
headers = await stream.ResponseHeadersAsync.ConfigureAwait(false);
173+
}
174+
catch (RpcException ex)
175+
{
176+
throw new SpiceException(
177+
SpiceStatus.FailedToAuthenticate,
178+
$"Failed to authenticate: {DescribeRpcFailure(ex)}",
179+
ex);
180+
}
181+
182+
// Trailers are only readable once the call has completed. Reading them
183+
// eagerly throws InvalidOperationException on a handshake that failed,
184+
// which masks the gRPC status that says what actually went wrong.
185+
RpcException? failure = null;
186+
var token = headers.Get("authorization");
187+
if (token == null)
188+
{
189+
token = TryGetTrailerToken(stream, out failure);
190+
}
191+
172192
if (token == null || _httpClient == null)
173193
{
174-
throw new SpiceException(SpiceStatus.FailedToAuthenticate, "Failed to authenticate");
194+
var reason = failure == null
195+
? "the runtime returned no authorization token."
196+
: DescribeRpcFailure(failure);
197+
198+
throw new SpiceException(
199+
SpiceStatus.FailedToAuthenticate,
200+
$"Failed to authenticate: {reason} Check that the API key is valid for this endpoint.",
201+
failure);
175202
}
176203

177204
_httpClient.DefaultRequestHeaders.Authorization = AuthenticationHeaderValue.Parse(token.Value);
178205
}
179206

207+
/// <summary>
208+
/// Reads the authorization token from the handshake trailers, if the call
209+
/// completed far enough for them to be available.
210+
/// </summary>
211+
/// <param name="stream">The handshake call</param>
212+
/// <param name="failure">The gRPC failure, when the trailers could not be read</param>
213+
/// <returns>The token entry, or null</returns>
214+
private static Metadata.Entry? TryGetTrailerToken(
215+
AsyncDuplexStreamingCall<FlightHandshakeRequest, FlightHandshakeResponse> stream,
216+
out RpcException? failure)
217+
{
218+
failure = null;
219+
try
220+
{
221+
return stream.GetTrailers().Get("authorization");
222+
}
223+
catch (RpcException ex)
224+
{
225+
failure = ex;
226+
return null;
227+
}
228+
catch (InvalidOperationException)
229+
{
230+
// The handshake never completed, so there are no trailers to read.
231+
return null;
232+
}
233+
}
234+
235+
/// <summary>
236+
/// Renders a gRPC failure as something a caller can act on.
237+
/// </summary>
238+
/// <param name="ex">The gRPC exception</param>
239+
/// <returns>A short description of the failure</returns>
240+
private static string DescribeRpcFailure(RpcException ex)
241+
{
242+
var detail = string.IsNullOrWhiteSpace(ex.Status.Detail) ? ex.Message : ex.Status.Detail;
243+
return $"{ex.StatusCode} - {detail}.";
244+
}
245+
180246
internal async Task<FlightClientRecordBatchStreamReader> Query(string sql)
181247
{
182248
if (string.IsNullOrEmpty(sql))

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)}.{nameof(request.Text)}");
164+
#else
165+
if (_disposed) throw new ObjectDisposedException(GetType().FullName);
166+
ThrowHelper.ThrowIfNull(request, nameof(request));
167+
ThrowHelper.ThrowIfNullOrWhiteSpace(request.Text, $"{nameof(request)}.{nameof(request.Text)}");
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>

0 commit comments

Comments
 (0)