diff --git a/Directory.Packages.props b/Directory.Packages.props index 875f181..99d9a19 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,7 +4,7 @@ true 8.6.0 8.2.0 - 9.0.0-preview.9.24507.7 + 9.0.0-preview.9.24525.1 @@ -18,11 +18,11 @@ + - @@ -34,10 +34,10 @@ - - - - + + + + diff --git a/seeddata/DataGenerator/Generators/TicketThreadGenerator.cs b/seeddata/DataGenerator/Generators/TicketThreadGenerator.cs index 5ac08da..6062fd5 100644 --- a/seeddata/DataGenerator/Generators/TicketThreadGenerator.cs +++ b/seeddata/DataGenerator/Generators/TicketThreadGenerator.cs @@ -164,16 +164,17 @@ private class AssistantTools(IEmbeddingGenerator> embed { // Obviously it would be more performant to chunk and embed each manual only once, but this is simpler for now var chunks = SplitIntoChunks(manual.MarkdownText, 200).ToList(); - var embeddings = await embedder.GenerateAsync(chunks); - var candidates = chunks.Zip(embeddings); - var queryEmbedding = (await embedder.GenerateAsync(query)).Single(); - - var closest = candidates - .Select(c => new { Text = c.First, Similarity = TensorPrimitives.CosineSimilarity(c.Second.Vector.Span, queryEmbedding.Vector.Span) }) - .OrderByDescending(c => c.Similarity) - .Take(3) - .Where(c => c.Similarity > 0.6f) - .ToList(); + + var candidates = await embedder.GenerateAndZipAsync(chunks); + var queryEmbedding = await embedder.GenerateEmbeddingAsync(query); + + var closest = + candidates + .Select(c => new { Text = c.Value, Similarity = TensorPrimitives.CosineSimilarity(c.Embedding.Vector.Span, queryEmbedding.Vector.Span) }) + .OrderByDescending(c => c.Similarity) + .Take(3) + .Where(c => c.Similarity > 0.6f) + .ToList(); if (closest.Any()) { diff --git a/src/Backend/Api/AssistantApi.cs b/src/Backend/Api/AssistantApi.cs index ab1131e..e5fcd94 100644 --- a/src/Backend/Api/AssistantApi.cs +++ b/src/Backend/Api/AssistantApi.cs @@ -1,4 +1,5 @@ using System.ComponentModel; +using System.Runtime.InteropServices; using System.Text; using System.Text.Json; using System.Text.RegularExpressions; @@ -105,24 +106,28 @@ public async Task SearchManual( await httpContext.Response.WriteAsync(JsonSerializer.Serialize(new AssistantChatReplyItem(AssistantChatReplyItemType.Search, searchPhrase))); // Do the search, and supply the results to the UI so it can show one as a citation link - var searchResults = await manualSearch.SearchAsync(productId, searchPhrase); + var searchResults = + await (await manualSearch.SearchAsync(productId, searchPhrase)) + .Results + .ToListAsync(); + foreach (var r in searchResults) { await httpContext.Response.WriteAsync(",\n"); await httpContext.Response.WriteAsync(JsonSerializer.Serialize(new AssistantChatReplyItem( AssistantChatReplyItemType.SearchResult, string.Empty, - int.Parse(r.Metadata.Id), - GetProductId(r), - GetPageNumber(r)))); + r.Record.ChunkId, // This is the ID of the record returned. Looking at the mapping, it was using the ChunkID + r.Record.ProductId, + r.Record.PageNumber))); } // Return the search results to the assistant return searchResults.Select(r => new { - ProductId = GetProductId(r), - SearchResultId = r.Metadata.Id, - r.Metadata.Text, + ProductId = r.Record.ProductId, + SearchResultId = r.Record.ChunkId, + r.Record.Text, }); } finally @@ -131,16 +136,4 @@ await httpContext.Response.WriteAsync(JsonSerializer.Serialize(new AssistantChat } } } - - private static int? GetProductId(MemoryQueryResult result) - { - var match = Regex.Match(result.Metadata.ExternalSourceName, @"productid:(\d+)"); - return match.Success ? int.Parse(match.Groups[1].Value) : null; - } - - private static int? GetPageNumber(MemoryQueryResult result) - { - var match = Regex.Match(result.Metadata.AdditionalMetadata, @"pagenumber:(\d+)"); - return match.Success ? int.Parse(match.Groups[1].Value) : null; - } } diff --git a/src/Backend/Data/ManualChunk.cs b/src/Backend/Data/ManualChunk.cs index 12108a1..f89c7a6 100644 --- a/src/Backend/Data/ManualChunk.cs +++ b/src/Backend/Data/ManualChunk.cs @@ -1,10 +1,18 @@ -namespace eShopSupport.Backend.Data; +using Microsoft.Extensions.VectorData; + +namespace eShopSupport.Backend.Data; public class ManualChunk { + [VectorStoreRecordData] public int ChunkId { get; set; } + [VectorStoreRecordKey] public int ProductId { get; set; } + [VectorStoreRecordData] public int PageNumber { get; set; } + [VectorStoreRecordData] public required string Text { get; set; } + + [VectorStoreRecordVector(384,DistanceFunction.CosineDistance)] public required byte[] Embedding { get; set; } } diff --git a/src/Backend/Services/ProductManualSemanticSearch.cs b/src/Backend/Services/ProductManualSemanticSearch.cs index fd9881a..06c0ba5 100644 --- a/src/Backend/Services/ProductManualSemanticSearch.cs +++ b/src/Backend/Services/ProductManualSemanticSearch.cs @@ -3,44 +3,36 @@ using System.Text.Json; using Azure.Storage.Blobs; using eShopSupport.Backend.Data; +using Microsoft.Extensions.VectorData; using Microsoft.SemanticKernel.Embeddings; using Microsoft.SemanticKernel.Memory; namespace eShopSupport.Backend.Services; -public class ProductManualSemanticSearch(ITextEmbeddingGenerationService embedder, IServiceProvider services) +public class ProductManualSemanticSearch(ITextEmbeddingGenerationService embedder, IVectorStore store) { private const string ManualCollectionName = "manuals"; - public async Task> SearchAsync(int? productId, string query) + public async Task> SearchAsync(int? productId, string query) { var embedding = await embedder.GenerateEmbeddingAsync(query); - var filter = !productId.HasValue - ? null - : new - { - must = new[] - { - new { key = "external_source_name", match = new { value = $"productid:{productId}" } } - } - }; - var httpClient = services.GetQdrantHttpClient("vector-db"); - var response = await httpClient.PostAsync($"collections/{ManualCollectionName}/points/search", - JsonContent.Create(new - { - vector = embedding, - with_payload = new[] { "id", "text", "external_source_name", "additional_metadata" }, - limit = 3, - filter, - })); - - var responseParsed = await response.Content.ReadFromJsonAsync(); - - return responseParsed!.Result.Select(r => new MemoryQueryResult( - new MemoryRecordMetadata(true, r.Payload.Id, r.Payload.Text, "", r.Payload.External_Source_Name, r.Payload.Additional_Metadata), - r.Score, - null)).ToList(); + var filter = new VectorSearchFilter([ + new EqualToFilterClause("external_source_name", $"productid:{productId}") + ]); + + + var searchOptions = new VectorSearchOptions + { + Filter = filter, + Top = 3 + }; + + var collection = store.GetCollection(ManualCollectionName); + + var results = await collection.VectorizedSearchAsync(embedding, searchOptions); + + return results; } public static async Task EnsureSeedDataImportedAsync(IServiceProvider services, string? initialImportDataDir) @@ -75,26 +67,33 @@ private static async Task ImportManualFilesSeedDataAsync(string importDataFromDi private static async Task ImportManualChunkSeedDataAsync(string importDataFromDir, IServiceScope scope) { - var semanticMemory = scope.ServiceProvider.GetRequiredService(); - var collections = await semanticMemory.GetCollectionsAsync().ToListAsync(); + var semanticMemory = scope.ServiceProvider.GetRequiredService(); + var collections = await semanticMemory.ListCollectionNamesAsync().ToListAsync(); if (!collections.Contains(ManualCollectionName)) { - await semanticMemory.CreateCollectionAsync(ManualCollectionName); + var collection = semanticMemory.GetCollection(ManualCollectionName); using var fileStream = File.OpenRead(Path.Combine(importDataFromDir, "manual-chunks.json")); var manualChunks = JsonSerializer.DeserializeAsyncEnumerable(fileStream); await foreach (var chunkChunk in ReadChunkedAsync(manualChunks, 1000)) { + var mappedRecords = chunkChunk.Select(chunk => { - var id = chunk!.ChunkId.ToString(); - var metadata = new MemoryRecordMetadata(false, id, chunk.Text, "", $"productid:{chunk.ProductId}", $"pagenumber:{chunk.PageNumber}"); - var embedding = MemoryMarshal.Cast(new ReadOnlySpan(chunk.Embedding)).ToArray(); - return new MemoryRecord(metadata, embedding, null); + }); - await foreach (var _ in semanticMemory.UpsertBatchAsync(ManualCollectionName, mappedRecords)) { } + + //var mappedRecords = chunkChunk.Select(chunk => + //{ + // var id = chunk!.ChunkId.ToString(); + // var metadata = new MemoryRecordMetadata(false, id, chunk.Text, "", $"productid:{chunk.ProductId}", $"pagenumber:{chunk.PageNumber}"); + // var embedding = MemoryMarshal.Cast(new ReadOnlySpan(chunk.Embedding)).ToArray(); + // return new MemoryRecord(metadata, embedding, null); + //}); + + //await foreach (var _ in semanticMemory.UpsertBatchAsync(ManualCollectionName, mappedRecords)) { } } } } diff --git a/src/ServiceDefaults/Clients/ChatCompletion/ServiceCollectionChatClientExtensions.cs b/src/ServiceDefaults/Clients/ChatCompletion/ServiceCollectionChatClientExtensions.cs index bf8268b..0e59332 100644 --- a/src/ServiceDefaults/Clients/ChatCompletion/ServiceCollectionChatClientExtensions.cs +++ b/src/ServiceDefaults/Clients/ChatCompletion/ServiceCollectionChatClientExtensions.cs @@ -1,4 +1,5 @@ using Azure.AI.OpenAI; +using OllamaSharp; using System.ClientModel; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; @@ -47,7 +48,7 @@ public static IServiceCollection AddOllamaChatClient( pipeline.UsePreventStreamingWithFunctions(); var httpClient = pipeline.Services.GetService() ?? new(); - return pipeline.Use(new OllamaChatClient(uri, modelName, httpClient)); + return pipeline.Use(new OllamaApiClient(httpClient, modelName)); }); } diff --git a/src/ServiceDefaults/ServiceDefaults.csproj b/src/ServiceDefaults/ServiceDefaults.csproj index ab367e1..1c2fb92 100644 --- a/src/ServiceDefaults/ServiceDefaults.csproj +++ b/src/ServiceDefaults/ServiceDefaults.csproj @@ -13,10 +13,10 @@ - +