Skip to content

Commit 27c93be

Browse files
authored
.Net Improve type safety: Return TRecord instead of object in ITextSearch.GetSearchResultsAsync (#13318)
This PR enhances the type safety of the `ITextSearch<TRecord>` interface by changing the `GetSearchResultsAsync` method to return `KernelSearchResults<TRecord>` instead of `KernelSearchResults<object>`. This improvement eliminates the need for manual casting and provides better IntelliSense support for consumers. ## Motivation and Context The current implementation of `ITextSearch<TRecord>.GetSearchResultsAsync` returns `KernelSearchResults<object>`, which requires consumers to manually cast results to the expected type. This reduces type safety and degrades the developer experience by losing compile-time type checking and IntelliSense support. This change aligns the return type with the generic type parameter `TRecord`, providing the expected strongly-typed results that users of a generic interface would anticipate. ## Changes Made ### Interface (ITextSearch.cs) - Changed `ITextSearch<TRecord>.GetSearchResultsAsync` return type from `KernelSearchResults<object>` to `KernelSearchResults<TRecord>` - Updated XML documentation to reflect strongly-typed return value - Legacy `ITextSearch` interface (non-generic) remains unchanged, continuing to return `KernelSearchResults<object>` for backward compatibility ### Implementation (VectorStoreTextSearch.cs) - Added new `GetResultsAsTRecordAsync` helper method returning `IAsyncEnumerable<TRecord>` - Updated generic interface implementation to use the new strongly-typed helper - Retained `GetResultsAsRecordAsync` method for the legacy non-generic interface ### Tests (VectorStoreTextSearchTests.cs) - Updated 3 unit tests to use strongly-typed `DataModel` or `DataModelWithRawEmbedding` instead of `object` - Improved test assertions to leverage direct property access without casting - All 19 tests pass successfully ## Breaking Changes **Interface Change (Experimental API):** - `ITextSearch<TRecord>.GetSearchResultsAsync` now returns `KernelSearchResults<TRecord>` instead of `KernelSearchResults<object>` - This interface is marked with `[Experimental("SKEXP0001")]`, indicating that breaking changes are expected during the preview period - Legacy `ITextSearch` interface (non-generic) is unaffected and maintains full backward compatibility ## Benefits - **Improved Type Safety**: Eliminates runtime casting errors by providing compile-time type checking - **Enhanced Developer Experience**: Full IntelliSense support for TRecord properties and methods - **Cleaner Code**: Consumers no longer need to cast results from object to the expected type - **Consistent API Design**: Generic interface now behaves as expected, returning strongly-typed results - **Zero Impact on Legacy Code**: Non-generic ITextSearch interface remains unchanged ## Testing - All 19 existing unit tests pass - Updated tests demonstrate improved type safety with direct property access - Verified both generic and legacy interfaces work correctly - Confirmed zero breaking changes to non-generic ITextSearch consumers ## Related Work This PR is part of the Issue #10456 multi-PR chain for modernizing ITextSearch with LINQ-based filtering: - PR #13175: Foundation (ITextSearch<TRecord> interface) - Merged - PR #13179: VectorStoreTextSearch + deprecation pattern - In Review - **This PR (2.1)**: API refinement for improved type safety - PR #13188-13191: Connector migrations (Bing, Google, Tavily, Brave) - Pending - PR #13194: Samples and documentation - Pending All PRs target the `feature-text-search-linq` branch for coordinated release. ## Migration Guide for Consumers ### Before (Previous API) ```csharp ITextSearch<DataModel> search = ...; KernelSearchResults<object> results = await search.GetSearchResultsAsync("query", options); foreach (var obj in results.Results) { var record = (DataModel)obj; // Manual cast required Console.WriteLine(record.Name); } ``` ### After (Improved API) ```csharp ITextSearch<DataModel> search = ...; KernelSearchResults<DataModel> results = await search.GetSearchResultsAsync("query", options); foreach (var record in results.Results) // Strongly typed! { Console.WriteLine(record.Name); // Direct property access with IntelliSense } ``` ## Checklist - [x] Changes build successfully - [x] All unit tests pass (19/19) - [x] XML documentation updated - [x] Breaking change documented (experimental API only) - [x] Legacy interface backward compatibility maintained - [x] Code follows project coding standards Co-authored-by: Alexander Zarei <alzarei@users.noreply.github.com>
1 parent ebd579d commit 27c93be

File tree

3 files changed

+35
-10
lines changed

3 files changed

+35
-10
lines changed

dotnet/src/SemanticKernel.Abstractions/Data/TextSearch/ITextSearch.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,12 @@ Task<KernelSearchResults<TextSearchResult>> GetTextSearchResultsAsync(
3636
CancellationToken cancellationToken = default);
3737

3838
/// <summary>
39-
/// Perform a search for content related to the specified query and return <see cref="object"/> values representing the search results.
39+
/// Perform a search for content related to the specified query and return strongly-typed <typeparamref name="TRecord"/> values representing the search results.
4040
/// </summary>
4141
/// <param name="query">What to search for.</param>
4242
/// <param name="searchOptions">Options used when executing a text search.</param>
4343
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
44-
Task<KernelSearchResults<object>> GetSearchResultsAsync(
44+
Task<KernelSearchResults<TRecord>> GetSearchResultsAsync(
4545
string query,
4646
TextSearchOptions<TRecord>? searchOptions = null,
4747
CancellationToken cancellationToken = default);

dotnet/src/SemanticKernel.Core/Data/TextSearch/VectorStoreTextSearch.cs

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -213,11 +213,11 @@ Task<KernelSearchResults<TextSearchResult>> ITextSearch<TRecord>.GetTextSearchRe
213213
}
214214

215215
/// <inheritdoc/>
216-
Task<KernelSearchResults<object>> ITextSearch<TRecord>.GetSearchResultsAsync(string query, TextSearchOptions<TRecord>? searchOptions, CancellationToken cancellationToken)
216+
Task<KernelSearchResults<TRecord>> ITextSearch<TRecord>.GetSearchResultsAsync(string query, TextSearchOptions<TRecord>? searchOptions, CancellationToken cancellationToken)
217217
{
218218
var searchResponse = this.ExecuteVectorSearchAsync(query, searchOptions, cancellationToken);
219219

220-
return Task.FromResult(new KernelSearchResults<object>(this.GetResultsAsRecordAsync(searchResponse, cancellationToken)));
220+
return Task.FromResult(new KernelSearchResults<TRecord>(this.GetResultsAsTRecordAsync(searchResponse, cancellationToken)));
221221
}
222222

223223
#region private
@@ -367,6 +367,28 @@ private async IAsyncEnumerable<object> GetResultsAsRecordAsync(IAsyncEnumerable<
367367
}
368368
}
369369

370+
/// <summary>
371+
/// Return the search results as strongly-typed <typeparamref name="TRecord"/> instances.
372+
/// </summary>
373+
/// <param name="searchResponse">Response containing the records matching the query.</param>
374+
/// <param name="cancellationToken">Cancellation token</param>
375+
private async IAsyncEnumerable<TRecord> GetResultsAsTRecordAsync(IAsyncEnumerable<VectorSearchResult<TRecord>>? searchResponse, [EnumeratorCancellation] CancellationToken cancellationToken)
376+
{
377+
if (searchResponse is null)
378+
{
379+
yield break;
380+
}
381+
382+
await foreach (var result in searchResponse.WithCancellation(cancellationToken).ConfigureAwait(false))
383+
{
384+
if (result.Record is not null)
385+
{
386+
yield return result.Record;
387+
await Task.Yield();
388+
}
389+
}
390+
}
391+
370392
/// <summary>
371393
/// Return the search results as instances of <see cref="TextSearchResult"/>.
372394
/// </summary>

dotnet/src/SemanticKernel.UnitTests/Data/VectorStoreTextSearchTests.cs

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -78,12 +78,14 @@ public async Task CanGetSearchResultAsync()
7878
{
7979
// Arrange.
8080
var sut = await CreateVectorStoreTextSearchAsync();
81+
ITextSearch<DataModel> typeSafeInterface = sut;
8182

8283
// Act.
83-
KernelSearchResults<object> searchResults = await sut.GetSearchResultsAsync("What is the Semantic Kernel?", new() { Top = 2, Skip = 0 });
84+
KernelSearchResults<DataModel> searchResults = await typeSafeInterface.GetSearchResultsAsync("What is the Semantic Kernel?", new TextSearchOptions<DataModel> { Top = 2, Skip = 0 });
8485
var results = await searchResults.Results.ToListAsync();
8586

8687
Assert.Equal(2, results.Count);
88+
Assert.All(results, result => Assert.IsType<DataModel>(result));
8789
}
8890

8991
[Fact]
@@ -117,12 +119,14 @@ public async Task CanGetSearchResultsWithEmbeddingGeneratorAsync()
117119
{
118120
// Arrange.
119121
var sut = await CreateVectorStoreTextSearchWithEmbeddingGeneratorAsync();
122+
ITextSearch<DataModelWithRawEmbedding> typeSafeInterface = sut;
120123

121124
// Act.
122-
KernelSearchResults<object> searchResults = await sut.GetSearchResultsAsync("What is the Semantic Kernel?", new() { Top = 2, Skip = 0 });
125+
KernelSearchResults<DataModelWithRawEmbedding> searchResults = await typeSafeInterface.GetSearchResultsAsync("What is the Semantic Kernel?", new TextSearchOptions<DataModelWithRawEmbedding> { Top = 2, Skip = 0 });
123126
var results = await searchResults.Results.ToListAsync();
124127

125128
Assert.Equal(2, results.Count);
129+
Assert.All(results, result => Assert.IsType<DataModelWithRawEmbedding>(result));
126130
}
127131

128132
#pragma warning disable CS0618 // VectorStoreTextSearch with ITextEmbeddingGenerationService is obsolete
@@ -270,17 +274,16 @@ public async Task LinqGetSearchResultsAsync()
270274
Filter = r => r.Tag == "Even"
271275
};
272276

273-
KernelSearchResults<object> searchResults = await typeSafeInterface.GetSearchResultsAsync(
277+
KernelSearchResults<DataModel> searchResults = await typeSafeInterface.GetSearchResultsAsync(
274278
"What is the Semantic Kernel?",
275279
searchOptions);
276280
var results = await searchResults.Results.ToListAsync();
277281

278-
// Assert - Results should be DataModel objects with Tag == "Even"
282+
// Assert - Results should be strongly-typed DataModel objects with Tag == "Even"
279283
Assert.NotEmpty(results);
280284
Assert.All(results, result =>
281285
{
282-
var dataModel = Assert.IsType<DataModel>(result);
283-
Assert.Equal("Even", dataModel.Tag);
286+
Assert.Equal("Even", result.Tag); // Direct property access - no cast needed!
284287
});
285288
}
286289

0 commit comments

Comments
 (0)