diff --git a/ai/select-algorithm-dotnet/.gitignore b/ai/select-algorithm-dotnet/.gitignore new file mode 100644 index 0000000..b0663a0 --- /dev/null +++ b/ai/select-algorithm-dotnet/.gitignore @@ -0,0 +1,11 @@ +# Build output +bin/ +obj/ + +# User settings +*.user +*.suo + +# Environment +.env +appsettings.Development.json diff --git a/ai/select-algorithm-dotnet/Program.cs b/ai/select-algorithm-dotnet/Program.cs new file mode 100644 index 0000000..85bfd4a --- /dev/null +++ b/ai/select-algorithm-dotnet/Program.cs @@ -0,0 +1,98 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using SelectAlgorithm.Services; +using SelectAlgorithm.Utilities; +using System.Reflection; + +namespace SelectAlgorithm; + +class Program +{ + static async Task Main(string[] args) + { + var configuration = new ConfigurationBuilder() + .SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) + .AddEnvironmentVariables() + .Build(); + + var services = new ServiceCollection() + .AddLogging(builder => builder + .AddConsole() + .SetMinimumLevel(LogLevel.Information)) + .AddSingleton(configuration); + + var serviceProvider = services.BuildServiceProvider(); + var logger = serviceProvider.GetRequiredService>(); + + try + { + var openAiEndpoint = configuration["AZURE_OPENAI_EMBEDDING_ENDPOINT"] + ?? throw new InvalidOperationException("AZURE_OPENAI_EMBEDDING_ENDPOINT not configured. Set as environment variable or in appsettings.json."); + var openAiModel = configuration["AZURE_OPENAI_EMBEDDING_MODEL"] + ?? throw new InvalidOperationException("AZURE_OPENAI_EMBEDDING_MODEL not configured. Set as environment variable or in appsettings.json."); + var mongoClusterName = configuration["MONGO_CLUSTER_NAME"] + ?? throw new InvalidOperationException("MONGO_CLUSTER_NAME not configured. Set as environment variable or in appsettings.json."); + var tenantId = configuration["AZURE_TENANT_ID"] + ?? throw new InvalidOperationException("AZURE_TENANT_ID not configured. Set as environment variable or in appsettings.json."); + + var databaseName = configuration["DatabaseName"] ?? "Hotels"; + var embeddedField = configuration["EmbeddedField"] ?? "DescriptionVector"; + if (!int.TryParse(configuration["EmbeddingDimensions"] ?? "1536", out int embeddingDimensions)) + throw new InvalidOperationException("EmbeddingDimensions must be a valid integer"); + if (!int.TryParse(configuration["LoadBatchSize"] ?? "100", out int loadBatchSize)) + throw new InvalidOperationException("LoadBatchSize must be a valid integer"); + var searchQuery = configuration["SearchQuery"] ?? "quintessential lodging near running trails, eateries, retail"; + if (!int.TryParse(configuration["TopK"] ?? "5", out int topK)) + throw new InvalidOperationException("TopK must be a valid integer"); + + var dataFileRelative = Environment.GetEnvironmentVariable("DATA_FILE_WITH_VECTORS") ?? "../../data/Hotels_Vector.json"; + var assemblyDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location) ?? string.Empty; + var dataFilePath = Path.GetFullPath(Path.Combine(assemblyDir, dataFileRelative)); + + var algorithmFilter = (Environment.GetEnvironmentVariable("ALGORITHM") ?? "all").Trim().ToLower(); + var similarityFilter = (Environment.GetEnvironmentVariable("SIMILARITY") ?? "COS").Trim().ToUpper(); + + logger.LogInformation("Initializing clients with passwordless authentication..."); + var (aiClient, dbClient) = Utils.GetClientsPasswordless(openAiEndpoint, mongoClusterName, tenantId); + + var comparisonLogger = serviceProvider.GetRequiredService() + .CreateLogger(); + + var comparisonService = new VectorComparisonService( + comparisonLogger, + aiClient, + dbClient, + databaseName, + embeddedField, + embeddingDimensions, + loadBatchSize, + topK + ); + + var results = await comparisonService.RunComparisonAsync( + dataFilePath, + searchQuery, + openAiModel, + algorithmFilter, + similarityFilter + ); + + if (results.Count > 0) + { + Utils.PrintComparisonTable(results); + } + + logger.LogInformation("\nClosing database connection..."); + // MongoClient does not implement IAsyncDisposable; sync disposal is intentional + dbClient?.Cluster?.Dispose(); + logger.LogInformation("Database connection closed"); + } + catch (Exception ex) + { + logger.LogError(ex, "Application failed"); + Environment.ExitCode = 1; + } + } +} diff --git a/ai/select-algorithm-dotnet/README.md b/ai/select-algorithm-dotnet/README.md new file mode 100644 index 0000000..eedcbde --- /dev/null +++ b/ai/select-algorithm-dotnet/README.md @@ -0,0 +1,124 @@ +# Select Algorithm - .NET + +Compare DiskANN, HNSW, and IVF vector index algorithms across COS, L2, and IP similarity metrics using Azure DocumentDB. + +## Prerequisites + +- .NET 9.0+ SDK +- Azure DocumentDB cluster +- Azure OpenAI resource with `text-embedding-3-small` deployment +- **RBAC roles**: + - `Cognitive Services OpenAI User` on the Azure OpenAI resource + - DocumentDB `dbOwner` role on the target database + +## Setup + +1. Set required environment variables using `dotnet user-secrets` or export them directly: + +```bash +# Using dotnet user-secrets (recommended for local development) +dotnet user-secrets init +dotnet user-secrets set "AZURE_OPENAI_EMBEDDING_ENDPOINT" "https://.openai.azure.com" +dotnet user-secrets set "AZURE_OPENAI_EMBEDDING_MODEL" "text-embedding-3-small" +dotnet user-secrets set "MONGO_CLUSTER_NAME" "" +dotnet user-secrets set "AZURE_TENANT_ID" "" + +# Or export as environment variables +export AZURE_OPENAI_EMBEDDING_ENDPOINT="https://.openai.azure.com" +export AZURE_OPENAI_EMBEDDING_MODEL="text-embedding-3-small" +export MONGO_CLUSTER_NAME="" +export AZURE_TENANT_ID="" +``` + +2. Restore dependencies: + +```bash +dotnet restore +``` + +## Usage + +### Compare all algorithms (default: COS similarity) + +```bash +dotnet run +``` + +Set `ALGORITHM` and `SIMILARITY` environment variables to control which collections are queried: + +| ALGORITHM | SIMILARITY | Collections queried | +|-----------|------------|---------------------| +| `all` | `COS` | 3 (one per algorithm, COS) | +| `all` | `all` | 9 (all combinations) | +| `diskann` | `COS` | 1 (hotels_diskann_cos) | +| `diskann` | `all` | 3 (diskann x all similarities) | + +## Architecture + +Creates collections named `hotels_{algorithm}_{similarity}` for each combination: + +| Algorithm | COS | L2 | IP | +|-----------|-----|----|----| +| DiskANN | `hotels_diskann_cos` | `hotels_diskann_l2` | `hotels_diskann_ip` | +| HNSW | `hotels_hnsw_cos` | `hotels_hnsw_l2` | `hotels_hnsw_ip` | +| IVF | `hotels_ivf_cos` | `hotels_ivf_l2` | `hotels_ivf_ip` | + +Each collection gets its own vector index created via `RunCommandAsync` and data inserted via `InsertManyAsync`. The application runs vector search aggregation queries and prints a comparison table with latency metrics. + +## Algorithm-Specific Parameters + +### Index Creation +- **DiskANN**: `maxDegree: 32`, `lBuild: 50` +- **HNSW**: `m: 16`, `efConstruction: 64` +- **IVF**: `numLists: 1` + +### Search Queries +- **DiskANN**: `lSearch: 100` +- **HNSW**: `efSearch: 80` +- **IVF**: `nProbes: 1` + +## Authentication + +Uses `DefaultAzureCredential` for passwordless authentication to both Azure OpenAI and DocumentDB. Ensure you are logged in with Azure CLI: + +```bash +az login +``` + +## Expected output + +Running with default settings (`ALGORITHM=all`, `SIMILARITY=COS`) prints a comparison table. Actual timings and scores vary per run. + +``` +========================================================================================== + Vector Algorithm Comparison Results +========================================================================================== +Algorithm Similarity Top Result Score Latency(ms) +------------------------------------------------------------------------------------------ +DiskANN COS Historic Downtown Inn 0.8342 45 +HNSW COS Historic Downtown Inn 0.8342 38 +IVF COS Historic Downtown Inn 0.8342 52 +========================================================================================== + +--- DiskANN / COS (hotels_diskann_cos) --- + 1. Historic Downtown Inn (Score: 0.8342) + 2. Mountain Trail Lodge (Score: 0.7891) + 3. Riverside Retreat (Score: 0.7654) + 4. Urban Fitness Suites (Score: 0.7210) + 5. Lakeside Wellness Resort (Score: 0.7045) +``` + +> Note: Results vary based on data, embeddings, and server load. The table above is representative only. + +## Troubleshooting + +| Problem | Resolution | +|---------|------------| +| **OIDC authentication failure** | Verify `DefaultAzureCredential` configuration. Run `az login` (or `az login --tenant `) and confirm the correct subscription is active. | +| **Data file not found** | Verify the `Hotels_Vector.json` path. By default the app resolves `../../data/Hotels_Vector.json` relative to the build output directory. Override with the `DATA_FILE_WITH_VECTORS` environment variable. | +| **Connection timeout** | Check network firewall rules and confirm the DocumentDB cluster is running and accessible. Ensure TLS is enabled and the cluster name is correct. | +| **Azure OpenAI errors** | Verify the endpoint URL, confirm the `text-embedding-3-small` model deployment exists, and ensure your identity has the `Cognitive Services OpenAI User` RBAC role. | + +## Important Notes + +- **Collection cleanup**: This sample drops and recreates collections on every run. Any existing data in collections matching the `hotels_{algorithm}_{similarity}` naming pattern will be deleted. diff --git a/ai/select-algorithm-dotnet/SelectAlgorithm.csproj b/ai/select-algorithm-dotnet/SelectAlgorithm.csproj new file mode 100644 index 0000000..a3084bc --- /dev/null +++ b/ai/select-algorithm-dotnet/SelectAlgorithm.csproj @@ -0,0 +1,34 @@ + + + + Exe + net9.0 + enable + enable + Compare DiskANN, HNSW, and IVF vector index algorithms using Azure DocumentDB + Microsoft Corporation + Microsoft Corporation + SelectAlgorithm + true + + + + + + + + + + + + + + + + + + PreserveNewest + + + + diff --git a/ai/select-algorithm-dotnet/Services/VectorComparisonService.cs b/ai/select-algorithm-dotnet/Services/VectorComparisonService.cs new file mode 100644 index 0000000..8dc0dd1 --- /dev/null +++ b/ai/select-algorithm-dotnet/Services/VectorComparisonService.cs @@ -0,0 +1,280 @@ +using Azure.AI.OpenAI; +using Microsoft.Extensions.Logging; +using MongoDB.Driver; +using MongoDB.Bson; +using SelectAlgorithm.Utilities; + +namespace SelectAlgorithm.Services; + +public class VectorComparisonService +{ + private readonly ILogger _logger; + private readonly AzureOpenAIClient _aiClient; + private readonly MongoClient _dbClient; + private readonly string _databaseName; + private readonly string _embeddedField; + private readonly int _embeddingDimensions; + private readonly int _loadBatchSize; + private readonly int _topK; + + private static readonly string[] Algorithms = ["diskann", "hnsw", "ivf"]; + private static readonly string[] Similarities = ["COS", "L2", "IP"]; + + private static readonly Dictionary AlgorithmLabels = new() + { + ["diskann"] = "DiskANN", + ["hnsw"] = "HNSW", + ["ivf"] = "IVF" + }; + + public VectorComparisonService( + ILogger logger, + AzureOpenAIClient aiClient, + MongoClient dbClient, + string databaseName, + string embeddedField, + int embeddingDimensions, + int loadBatchSize, + int topK) + { + _logger = logger; + _aiClient = aiClient; + _dbClient = dbClient; + _databaseName = databaseName; + _embeddedField = embeddedField; + _embeddingDimensions = embeddingDimensions; + _loadBatchSize = loadBatchSize; + _topK = topK; + } + + public async Task> RunComparisonAsync( + string dataFilePath, + string searchQuery, + string embeddingModel, + string algorithmFilter, + string similarityFilter) + { + var targets = GetTargetCollections(algorithmFilter, similarityFilter); + + _logger.LogInformation("\nVector Algorithm Comparison"); + _logger.LogInformation(" Database: {DatabaseName}", _databaseName); + _logger.LogInformation(" Algorithms: {AlgorithmFilter}", algorithmFilter); + _logger.LogInformation(" Similarity: {SimilarityFilter}", similarityFilter); + _logger.LogInformation(" Collections to query: {Collections}", string.Join(", ", targets.Select(t => t.CollectionName))); + _logger.LogInformation(" Search query: \"{SearchQuery}\"", searchQuery); + + var db = _dbClient.GetDatabase(_databaseName); + var data = await Utils.ReadJsonFileAsync(dataFilePath); + + _logger.LogInformation("Generating query embedding..."); + var embeddingClient = _aiClient.GetEmbeddingClient(embeddingModel); + var embeddingResponse = await embeddingClient.GenerateEmbeddingAsync(searchQuery); + var queryEmbedding = embeddingResponse.Value.ToFloats().ToArray(); + if (queryEmbedding.Length != _embeddingDimensions) + { + throw new InvalidOperationException( + $"Embedding dimension mismatch: expected {_embeddingDimensions}, got {queryEmbedding.Length}. " + + $"Verify the model matches the configured EmbeddingDimensions in appsettings.json."); + } + _logger.LogInformation("Query embedding: {Dimensions} dimensions", queryEmbedding.Length); + + var comparisonResults = new List(); + + foreach (var target in targets) + { + _logger.LogInformation("--- {Algorithm} / {Similarity} ---", AlgorithmLabels[target.Algorithm], target.Similarity); + _logger.LogInformation("Collection: {CollectionName}", target.CollectionName); + + try + { + try + { + await db.DropCollectionAsync(target.CollectionName); + } + catch (Exception ex) + { + _logger.LogDebug("Could not drop collection {Name}: {Message}", target.CollectionName, ex.Message); + } + + await db.CreateCollectionAsync(target.CollectionName); + _logger.LogInformation("Created collection: {CollectionName}", target.CollectionName); + + var collection = db.GetCollection(target.CollectionName); + + var (inserted, failed) = await Utils.InsertDataAsync(collection, data, _loadBatchSize); + _logger.LogInformation("Inserted: {Inserted}/{Total}", inserted, data.Count); + + var indexName = $"vectorIndex_{target.Algorithm}_{target.Similarity.ToLower()}"; + var indexOptions = GetIndexOptions( + target.CollectionName, + indexName, + _embeddedField, + _embeddingDimensions, + target.Algorithm, + target.Similarity + ); + await db.RunCommandAsync(indexOptions); + _logger.LogInformation("Created vector index: {IndexName}", indexName); + + _logger.LogInformation("Executing vector search..."); + var startTime = DateTime.UtcNow; + + var pipeline = GetSearchPipeline(queryEmbedding, _embeddedField, _topK, target.Algorithm); + var searchResults = await collection.Aggregate(pipeline).ToListAsync(); + + var latencyMs = (DateTime.UtcNow - startTime).TotalMilliseconds; + + var results = searchResults.Select(doc => new SearchResult + { + Document = new HotelData + { + HotelName = doc["document"].AsBsonDocument.GetValue("HotelName", "Unknown").AsString + }, + Score = doc["score"].ToDouble() + }).ToList(); + + comparisonResults.Add(new ComparisonResult + { + CollectionName = target.CollectionName, + Algorithm = AlgorithmLabels[target.Algorithm], + Similarity = target.Similarity, + SearchResults = results, + LatencyMs = latencyMs + }); + + _logger.LogInformation("[OK] {ResultCount} results, {LatencyMs}ms", results.Count, latencyMs.ToString("F0")); + } + catch (Azure.RequestFailedException ex) + { + _logger.LogError("Azure service error (HTTP {Status}): {Message}", ex.Status, ex.Message); + } + catch (MongoException ex) + { + _logger.LogError("MongoDB error: {Message}", ex.Message); + } + catch (Exception ex) + { + _logger.LogError("Unexpected error comparing algorithms: {Message}", ex.Message); + } + } + + return comparisonResults; + } + + private List<(string CollectionName, string Algorithm, string Similarity)> GetTargetCollections( + string algorithmEnv, + string similarityEnv) + { + var algorithms = algorithmEnv.ToLower() == "all" + ? Algorithms + : new[] { algorithmEnv.ToLower() }; + + var similarities = similarityEnv.ToUpper() == "ALL" + ? Similarities + : new[] { similarityEnv.ToUpper() }; + + var targets = new List<(string, string, string)>(); + + foreach (var alg in algorithms) + { + if (!Algorithms.Contains(alg)) + { + throw new ArgumentException($"Invalid ALGORITHM '{alg}'. Must be one of: all, {string.Join(", ", Algorithms)}"); + } + + foreach (var sim in similarities) + { + if (!Similarities.Contains(sim)) + { + throw new ArgumentException($"Invalid SIMILARITY '{sim}'. Must be one of: all, {string.Join(", ", Similarities)}"); + } + + targets.Add(($"hotels_{alg}_{sim.ToLower()}", alg, sim)); + } + } + + return targets; + } + + private BsonDocument GetIndexOptions( + string collectionName, + string indexName, + string embeddedField, + int dimensions, + string algorithm, + string similarity) + { + var cosmosSearchOptions = new BsonDocument + { + ["kind"] = $"vector-{algorithm}", + ["dimensions"] = dimensions, + ["similarity"] = similarity + }; + + switch (algorithm) + { + case "diskann": + cosmosSearchOptions["maxDegree"] = 32; + cosmosSearchOptions["lBuild"] = 50; + break; + case "hnsw": + cosmosSearchOptions["m"] = 16; + cosmosSearchOptions["efConstruction"] = 64; + break; + case "ivf": + cosmosSearchOptions["numLists"] = 1; + break; + } + + return new BsonDocument + { + ["createIndexes"] = collectionName, + ["indexes"] = new BsonArray + { + new BsonDocument + { + ["name"] = indexName, + ["key"] = new BsonDocument { [embeddedField] = "cosmosSearch" }, + ["cosmosSearchOptions"] = cosmosSearchOptions + } + } + }; + } + + private PipelineDefinition GetSearchPipeline( + float[] queryEmbedding, + string embeddedField, + int k, + string algorithm) + { + var cosmosSearch = new BsonDocument + { + ["vector"] = new BsonArray(queryEmbedding.Select(f => new BsonDouble(f))), + ["path"] = embeddedField, + ["k"] = k + }; + + switch (algorithm) + { + case "diskann": + cosmosSearch["lSearch"] = 100; + break; + case "hnsw": + cosmosSearch["efSearch"] = 80; + break; + case "ivf": + cosmosSearch["nProbes"] = 1; + break; + } + + return new BsonDocument[] + { + new BsonDocument("$search", new BsonDocument { ["cosmosSearch"] = cosmosSearch }), + new BsonDocument("$project", new BsonDocument + { + ["score"] = new BsonDocument("$meta", "searchScore"), + ["document"] = "$$ROOT" + }) + }; + } +} diff --git a/ai/select-algorithm-dotnet/Utilities/Utils.cs b/ai/select-algorithm-dotnet/Utilities/Utils.cs new file mode 100644 index 0000000..74a9891 --- /dev/null +++ b/ai/select-algorithm-dotnet/Utilities/Utils.cs @@ -0,0 +1,191 @@ +using Azure.Core; +using Azure.Identity; +using Azure.AI.OpenAI; +using MongoDB.Driver; +using MongoDB.Driver.Authentication.Oidc; +using MongoDB.Bson; +using System.Text.Json; + +namespace SelectAlgorithm.Utilities; + +internal sealed class AzureIdentityTokenHandler(TokenCredential credential, string tenantId) : IOidcCallback +{ + private readonly string[] scopes = ["https://ossrdbms-aad.database.windows.net/.default"]; + + // Note: OIDC tokens expire after approximately 1 hour. + // MongoDB driver handles token refresh automatically via this callback. + public OidcAccessToken GetOidcAccessToken(OidcCallbackParameters parameters, CancellationToken cancellationToken) + { + AccessToken token = credential.GetToken( + new TokenRequestContext(scopes, tenantId: tenantId), + cancellationToken + ); + + return new OidcAccessToken(token.Token, token.ExpiresOn - DateTimeOffset.UtcNow); + } + + public async Task GetOidcAccessTokenAsync(OidcCallbackParameters parameters, CancellationToken cancellationToken) + { + AccessToken token = await credential.GetTokenAsync( + new TokenRequestContext(scopes, parentRequestId: null, tenantId: tenantId), + cancellationToken + ); + + return new OidcAccessToken(token.Token, token.ExpiresOn - DateTimeOffset.UtcNow); + } +} + +public static class Utils +{ + public static (AzureOpenAIClient aiClient, MongoClient dbClient) GetClientsPasswordless( + string openAiEndpoint, + string mongoClusterName, + string tenantId) + { + var credential = new DefaultAzureCredential(new DefaultAzureCredentialOptions + { + TenantId = tenantId + }); + + var options = new AzureOpenAIClientOptions(); + // Default retry policy (3 retries with exponential backoff) is applied automatically + var aiClient = new AzureOpenAIClient(new Uri(openAiEndpoint), credential, options); + + var connectionString = $"mongodb+srv://{mongoClusterName}.mongocluster.cosmos.azure.com/?tls=true&authMechanism=MONGODB-OIDC&retrywrites=false&maxIdleTimeMS=120000"; + var settings = MongoClientSettings.FromUrl(MongoUrl.Create(connectionString)); + settings.UseTls = true; + settings.RetryWrites = false; + settings.MaxConnectionIdleTime = TimeSpan.FromMinutes(2); + settings.Credential = MongoCredential.CreateOidcCredential(new AzureIdentityTokenHandler(credential, tenantId)); + settings.Freeze(); + + var dbClient = new MongoClient(settings); + + return (aiClient, dbClient); + } + + // Console.WriteLine is used intentionally in Utils methods for demo output, + // keeping utility methods simple without requiring ILogger dependency injection. + public static async Task> ReadJsonFileAsync(string filePath) + { + Console.WriteLine($"Reading JSON file from {filePath}"); + var jsonContent = await File.ReadAllTextAsync(filePath); + var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + return JsonSerializer.Deserialize>(jsonContent, options) ?? new List(); + } + + public static async Task<(int inserted, int failed)> InsertDataAsync( + IMongoCollection collection, + List data, + int batchSize) where T : class + { + Console.WriteLine($"Processing in batches of {batchSize}..."); + int totalBatches = (int)Math.Ceiling((double)data.Count / batchSize); + int inserted = 0; + int failed = 0; + + for (int i = 0; i < totalBatches; i++) + { + var batch = data.Skip(i * batchSize).Take(batchSize).ToList(); + try + { + await collection.InsertManyAsync(batch, new InsertManyOptions { IsOrdered = false }); + inserted += batch.Count; + Console.WriteLine($"Batch {i + 1} complete: {batch.Count} inserted"); + } + catch (Exception ex) + { + Console.WriteLine($"Error in batch {i + 1}: {ex.Message}"); + failed += batch.Count; + } + + if (i < totalBatches - 1) + { + await Task.Delay(100); + } + } + + var indexColumns = new[] { "HotelId", "Category", "Description", "Description_fr" }; + foreach (var col in indexColumns) + { + await collection.Indexes.CreateOneAsync( + new CreateIndexModel(Builders.IndexKeys.Ascending(col)) + ); + } + + return (inserted, failed); + } + + public static void PrintComparisonTable(List results) + { + Console.WriteLine("\n" + new string('=', 90)); + Console.WriteLine(" Vector Algorithm Comparison Results"); + Console.WriteLine(new string('=', 90)); + + Console.WriteLine( + "Algorithm".PadRight(14) + + "Similarity".PadRight(14) + + "Top Result".PadRight(26) + + "Score".PadRight(14) + + "Latency(ms)" + ); + Console.WriteLine(new string('-', 90)); + + foreach (var r in results) + { + var topResult = r.SearchResults.FirstOrDefault(); + var topName = topResult != null + ? (topResult.Document.HotelName?.Length > 24 + ? topResult.Document.HotelName.Substring(0, 24) + : topResult.Document.HotelName ?? "N/A") + : "N/A"; + var topScore = topResult != null ? topResult.Score.ToString("F4") : "N/A"; + + Console.WriteLine( + r.Algorithm.PadRight(14) + + r.Similarity.PadRight(14) + + topName.PadRight(26) + + topScore.PadRight(14) + + r.LatencyMs.ToString("F0") + ); + } + + Console.WriteLine(new string('=', 90)); + + foreach (var r in results) + { + Console.WriteLine($"\n--- {r.Algorithm} / {r.Similarity} ({r.CollectionName}) ---"); + if (r.SearchResults.Count == 0) + { + Console.WriteLine(" No results."); + continue; + } + for (int i = 0; i < r.SearchResults.Count; i++) + { + var item = r.SearchResults[i]; + Console.WriteLine($" {i + 1}. {item.Document.HotelName}, Score: {item.Score:F4}"); + } + Console.WriteLine($" Latency: {r.LatencyMs:F0}ms"); + } + } +} + +public class ComparisonResult +{ + public string CollectionName { get; set; } = string.Empty; + public string Algorithm { get; set; } = string.Empty; + public string Similarity { get; set; } = string.Empty; + public List SearchResults { get; set; } = new(); + public double LatencyMs { get; set; } +} + +public class SearchResult +{ + public HotelData Document { get; set; } = new(); + public double Score { get; set; } +} + +public class HotelData +{ + public string? HotelName { get; set; } +} diff --git a/ai/select-algorithm-dotnet/appsettings.json b/ai/select-algorithm-dotnet/appsettings.json new file mode 100644 index 0000000..71f5211 --- /dev/null +++ b/ai/select-algorithm-dotnet/appsettings.json @@ -0,0 +1,8 @@ +{ + "DatabaseName": "Hotels", + "EmbeddedField": "DescriptionVector", + "EmbeddingDimensions": 1536, + "LoadBatchSize": 100, + "SearchQuery": "quintessential lodging near running trails, eateries, retail", + "TopK": 5 +} diff --git a/ai/select-algorithm-dotnet/global.json b/ai/select-algorithm-dotnet/global.json new file mode 100644 index 0000000..a02bac5 --- /dev/null +++ b/ai/select-algorithm-dotnet/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "9.0.200", + "rollForward": "latestFeature" + } +} diff --git a/ai/select-algorithm-dotnet/packages.lock.json b/ai/select-algorithm-dotnet/packages.lock.json new file mode 100644 index 0000000..e6c4241 --- /dev/null +++ b/ai/select-algorithm-dotnet/packages.lock.json @@ -0,0 +1,363 @@ +{ + "version": 1, + "dependencies": { + "net9.0": { + "Azure.AI.OpenAI": { + "type": "Direct", + "requested": "[2.1.0, )", + "resolved": "2.1.0", + "contentHash": "doixr3tcsIcdzbzF9NxKt+6U0NKj6aPeOCPYONPsWjyf3gxVndVJAdjPcVdqeR/3vcRHXRgGnGlv+iXx5jMA4g==", + "dependencies": { + "Azure.Core": "1.44.1", + "OpenAI": "2.1.0" + } + }, + "Azure.Identity": { + "type": "Direct", + "requested": "[1.17.1, )", + "resolved": "1.17.1", + "contentHash": "MSZkBrctcpiGxs9Cvr2VKKoN6qFLZlP3I6xuCWJ9iTgitI5Rgxtk5gfOSpXPZE3+CJmZ/mnqpQyGyjawFn5Vvg==", + "dependencies": { + "Azure.Core": "1.50.0", + "Microsoft.Identity.Client": "4.78.0", + "Microsoft.Identity.Client.Extensions.Msal": "4.78.0" + } + }, + "Microsoft.Extensions.Configuration": { + "type": "Direct", + "requested": "[9.0.0, )", + "resolved": "9.0.0", + "contentHash": "YIMO9T3JL8MeEXgVozKt2v79hquo/EFtnY0vgxmLnUvk1Rei/halI7kOWZL2RBeV9FMGzgM9LZA8CVaNwFMaNA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "9.0.0", + "Microsoft.Extensions.Primitives": "9.0.0" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "Direct", + "requested": "[9.0.0, )", + "resolved": "9.0.0", + "contentHash": "RiScL99DcyngY9zJA2ROrri7Br8tn5N4hP4YNvGdTN/bvg1A3dwvDOxHnNZ3Im7x2SJ5i4LkX1uPiR/MfSFBLQ==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "9.0.0" + } + }, + "Microsoft.Extensions.Configuration.EnvironmentVariables": { + "type": "Direct", + "requested": "[9.0.0, )", + "resolved": "9.0.0", + "contentHash": "v5R638eNMxksfXb7MFnkPwLPp+Ym4W/SIGNuoe8qFVVyvygQD5DdLusybmYSJEr9zc1UzWzim/ATKeIOVvOFDg==", + "dependencies": { + "Microsoft.Extensions.Configuration": "9.0.0", + "Microsoft.Extensions.Configuration.Abstractions": "9.0.0" + } + }, + "Microsoft.Extensions.Configuration.Json": { + "type": "Direct", + "requested": "[9.0.0, )", + "resolved": "9.0.0", + "contentHash": "WiTK0LrnsqmedrbzwL7f4ZUo+/wByqy2eKab39I380i2rd8ImfCRMrtkqJVGDmfqlkP/YzhckVOwPc5MPrSNpg==", + "dependencies": { + "Microsoft.Extensions.Configuration": "9.0.0", + "Microsoft.Extensions.Configuration.Abstractions": "9.0.0", + "Microsoft.Extensions.Configuration.FileExtensions": "9.0.0", + "Microsoft.Extensions.FileProviders.Abstractions": "9.0.0" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Direct", + "requested": "[9.0.0, )", + "resolved": "9.0.0", + "contentHash": "MCPrg7v3QgNMr0vX4vzRXvkNGgLg8vKWX0nKCWUxu2uPyMsaRgiRc1tHBnbTcfJMhMKj2slE/j2M9oGkd25DNw==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0" + } + }, + "Microsoft.Extensions.Logging": { + "type": "Direct", + "requested": "[9.0.0, )", + "resolved": "9.0.0", + "contentHash": "crjWyORoug0kK7RSNJBTeSE6VX8IQgLf3nUpTB9m62bPXp/tzbnOsnbe8TXEG0AASNaKZddnpHKw7fET8E++Pg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "9.0.0", + "Microsoft.Extensions.Logging.Abstractions": "9.0.0", + "Microsoft.Extensions.Options": "9.0.0" + } + }, + "Microsoft.Extensions.Logging.Console": { + "type": "Direct", + "requested": "[9.0.0, )", + "resolved": "9.0.0", + "contentHash": "yDZ4zsjl7N0K+R/1QTNpXBd79Kaf4qNLHtjk4NaG82UtNg2Z6etJywwv6OarOv3Rp7ocU7uIaRY4CrzHRO/d3w==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0", + "Microsoft.Extensions.Logging": "9.0.0", + "Microsoft.Extensions.Logging.Abstractions": "9.0.0", + "Microsoft.Extensions.Logging.Configuration": "9.0.0", + "Microsoft.Extensions.Options": "9.0.0" + } + }, + "MongoDB.Driver": { + "type": "Direct", + "requested": "[3.0.0, )", + "resolved": "3.0.0", + "contentHash": "udcP8rOhyuhLDn3sGVdNUgQSXfKGPaIP4w09XVKf4xdy66YSXinhkIuQSuOeZVHdTFsG2PpUbRx2wyFm7E0EMg==", + "dependencies": { + "DnsClient": "1.6.1", + "Microsoft.Extensions.Logging.Abstractions": "2.0.0", + "MongoDB.Bson": "3.0.0", + "SharpCompress": "0.30.1", + "Snappier": "1.0.0", + "System.Buffers": "4.5.1", + "ZstdSharp.Port": "0.7.3" + } + }, + "Azure.Core": { + "type": "Transitive", + "resolved": "1.50.0", + "contentHash": "GBNKZEhdIbTXxedvD3R7I/yDVFX9jJJEz02kCziFSJxspSQ5RMHc3GktulJ1s7+ffXaXD7kMgrtdQTaggyInLw==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "8.0.0", + "System.ClientModel": "1.8.0", + "System.Memory.Data": "8.0.1" + } + }, + "DnsClient": { + "type": "Transitive", + "resolved": "1.6.1", + "contentHash": "4H/f2uYJOZ+YObZjpY9ABrKZI+JNw3uizp6oMzTXwDw6F+2qIPhpRl/1t68O/6e98+vqNiYGu+lswmwdYUy3gg==", + "dependencies": { + "Microsoft.Win32.Registry": "5.0.0" + } + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "3WA9q9yVqJp222P3x1wYIGDAkpjAku0TMUaaQV22g6L67AI0LdOIrVS7Ht2vJfLHGSPVuqN94vIr15qn+HEkHw==" + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "lqvd7W3FGKUO1+ZoUEMaZ5XDJeWvjpy2/M/ptCGz3tXLD4HWVaSzjufsAsjemasBEg+2SxXVtYVvGt5r2nKDlg==", + "dependencies": { + "Microsoft.Extensions.Primitives": "9.0.0" + } + }, + "Microsoft.Extensions.Configuration.FileExtensions": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "4EK93Jcd2lQG4GY6PAw8jGss0ZzFP0vPc1J85mES5fKNuDTqgFXHba9onBw2s18fs3I4vdo2AWyfD1mPAxWSQQ==", + "dependencies": { + "Microsoft.Extensions.Configuration": "9.0.0", + "Microsoft.Extensions.Configuration.Abstractions": "9.0.0", + "Microsoft.Extensions.FileProviders.Abstractions": "9.0.0", + "Microsoft.Extensions.FileProviders.Physical": "9.0.0", + "Microsoft.Extensions.Primitives": "9.0.0" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "+6f2qv2a3dLwd5w6JanPIPs47CxRbnk+ZocMJUhv9NxP88VlOcJYZs9jY+MYSjxvady08bUZn6qgiNh7DadGgg==" + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "uK439QzYR0q2emLVtYzwyK3x+T5bTY4yWsd/k/ZUS9LR6Sflp8MIdhGXW8kQCd86dQD4tLqvcbLkku8qHY263Q==", + "dependencies": { + "Microsoft.Extensions.Primitives": "9.0.0" + } + }, + "Microsoft.Extensions.FileProviders.Physical": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "3+ZUSpOSmie+o8NnLIRqCxSh65XL/ExU7JYnFOg58awDRlY3lVpZ9A369jkoZL1rpsq7LDhEfkn2ghhGaY1y5Q==", + "dependencies": { + "Microsoft.Extensions.FileProviders.Abstractions": "9.0.0", + "Microsoft.Extensions.FileSystemGlobbing": "9.0.0", + "Microsoft.Extensions.Primitives": "9.0.0" + } + }, + "Microsoft.Extensions.FileSystemGlobbing": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "jGFKZiXs2HNseK3NK/rfwHNNovER71jSj4BD1a/649ml9+h6oEtYd0GSALZDNW8jZ2Rh+oAeadOa6sagYW1F2A==" + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "g0UfujELzlLbHoVG8kPKVBaW470Ewi+jnptGS9KUi6jcb+k2StujtK3m26DFSGGwQ/+bVgZfsWqNzlP6YOejvw==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0" + } + }, + "Microsoft.Extensions.Logging.Configuration": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "H05HiqaNmg6GjH34ocYE9Wm1twm3Oz2aXZko8GTwGBzM7op2brpAA8pJ5yyD1OpS1mXUtModBYOlcZ/wXeWsSg==", + "dependencies": { + "Microsoft.Extensions.Configuration": "9.0.0", + "Microsoft.Extensions.Configuration.Abstractions": "9.0.0", + "Microsoft.Extensions.Configuration.Binder": "9.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0", + "Microsoft.Extensions.Logging": "9.0.0", + "Microsoft.Extensions.Logging.Abstractions": "9.0.0", + "Microsoft.Extensions.Options": "9.0.0", + "Microsoft.Extensions.Options.ConfigurationExtensions": "9.0.0" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "y2146b3jrPI3Q0lokKXdKLpmXqakYbDIPDV6r3M8SqvSf45WwOTzkyfDpxnZXJsJQEpAsAqjUq5Pu8RCJMjubg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0", + "Microsoft.Extensions.Primitives": "9.0.0" + } + }, + "Microsoft.Extensions.Options.ConfigurationExtensions": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "Ob3FXsXkcSMQmGZi7qP07EQ39kZpSBlTcAZLbJLdI4FIf0Jug8biv2HTavWmnTirchctPlq9bl/26CXtQRguzA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "9.0.0", + "Microsoft.Extensions.Configuration.Binder": "9.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0", + "Microsoft.Extensions.Options": "9.0.0", + "Microsoft.Extensions.Primitives": "9.0.0" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "N3qEBzmLMYiASUlKxxFIISP4AiwuPTHF5uCh+2CWSwwzAJiIYx0kBJsS30cp1nvhSySFAVi30jecD307jV+8Kg==" + }, + "Microsoft.Identity.Client": { + "type": "Transitive", + "resolved": "4.78.0", + "contentHash": "vZ50HE9INSN+Ew8pCgTm0t7wzxQTqozF9L4MAsl64etXz0Teo0dbUvjpVzqDHRs6m1Vn8mHF04fGaxXrIvGpsg==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "8.14.0", + "System.Diagnostics.DiagnosticSource": "6.0.1" + } + }, + "Microsoft.Identity.Client.Extensions.Msal": { + "type": "Transitive", + "resolved": "4.78.0", + "contentHash": "DYU9o+DrDQuyZxeq91GBA9eNqBvA3ZMkLzQpF7L9dTk6FcIBM1y1IHXWqiKXTvptPF7CZE59upbyUoa+FJ5eiA==", + "dependencies": { + "Microsoft.Identity.Client": "4.78.0", + "System.Security.Cryptography.ProtectedData": "4.5.0" + } + }, + "Microsoft.IdentityModel.Abstractions": { + "type": "Transitive", + "resolved": "8.14.0", + "contentHash": "iwbCpSjD3ehfTwBhtSNEtKPK0ICun6ov7Ibx6ISNA9bfwIyzI2Siwyi9eJFCJBwxowK9xcA1mj+jBWiigeqgcQ==" + }, + "Microsoft.NETCore.Platforms": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "VyPlqzH2wavqquTcYpkIIAQ6WdenuKoFN0BdYBbCWsclXacSOHNQn66Gt4z5NBqEYW0FAPm5rlvki9ZiCij5xQ==" + }, + "Microsoft.Win32.Registry": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==", + "dependencies": { + "System.Security.AccessControl": "5.0.0", + "System.Security.Principal.Windows": "5.0.0" + } + }, + "MongoDB.Bson": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "qnPRJ58HXDh7C4oxTf6YB7BJhlCGJIa6TMXhzImw6zk44lrAomQXTB6AtoQ5lNJbkyrgQcT7+smsKFMnXmLXhw==", + "dependencies": { + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "5.0.0" + } + }, + "OpenAI": { + "type": "Transitive", + "resolved": "2.1.0", + "contentHash": "zl72nyP4O94ZyEoCLoE8qoc5vp8kSJmRuqR+UdK+jNIjFJGWJYjsb4rAGpMWWRTbPYuk82E8myMJdVQurMLDnQ==", + "dependencies": { + "System.ClientModel": "1.2.1", + "System.Diagnostics.DiagnosticSource": "6.0.1" + } + }, + "SharpCompress": { + "type": "Transitive", + "resolved": "0.30.1", + "contentHash": "XqD4TpfyYGa7QTPzaGlMVbcecKnXy4YmYLDWrU+JIj7IuRNl7DH2END+Ll7ekWIY8o3dAMWLFDE1xdhfIWD1nw==" + }, + "Snappier": { + "type": "Transitive", + "resolved": "1.0.0", + "contentHash": "rFtK2KEI9hIe8gtx3a0YDXdHOpedIf9wYCEYtBEmtlyiWVX3XlCNV03JrmmAi/Cdfn7dxK+k0sjjcLv4fpHnqA==" + }, + "System.Buffers": { + "type": "Transitive", + "resolved": "4.5.1", + "contentHash": "Rw7ijyl1qqRS0YQD/WycNst8hUUMgrMH4FCn1nNm27M4VxchZ1js3fVjQaANHO5f3sN4isvP4a+Met9Y4YomAg==" + }, + "System.ClientModel": { + "type": "Transitive", + "resolved": "1.8.0", + "contentHash": "AqRzhn0v29GGGLj/Z6gKq4lGNtvPHT4nHdG5PDJh9IfVjv/nYUVmX11hwwws1vDFeIAzrvmn0dPu8IjLtu6fAw==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "8.0.3", + "System.Memory.Data": "8.0.1" + } + }, + "System.Diagnostics.DiagnosticSource": { + "type": "Transitive", + "resolved": "6.0.1", + "contentHash": "KiLYDu2k2J82Q9BJpWiuQqCkFjRBWVq4jDzKKWawVi9KWzyD0XG3cmfX0vqTQlL14Wi9EufJrbL0+KCLTbqWiQ==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.5.5", + "contentHash": "XIWiDvKPXaTveaB7HVganDlOCRoj03l+jrwNvcge/t8vhGYKvqV+dMv6G4SAX2NoNmN0wZfVPTAlFwZcZvVOUw==" + }, + "System.Memory.Data": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "BVYuec3jV23EMRDeR7Dr1/qhx7369dZzJ9IWy2xylvb4YfXsrUxspWc4UWYid/tj4zZK58uGZqn2WQiaDMhmAg==" + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==" + }, + "System.Security.AccessControl": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dagJ1mHZO3Ani8GH0PHpPEe/oYO+rVdbQjvjJkBRNQkX4t0r1iaeGn8+/ybkSLEan3/slM0t59SVdHzuHf2jmw==", + "dependencies": { + "Microsoft.NETCore.Platforms": "5.0.0", + "System.Security.Principal.Windows": "5.0.0" + } + }, + "System.Security.Cryptography.ProtectedData": { + "type": "Transitive", + "resolved": "4.5.0", + "contentHash": "wLBKzFnDCxP12VL9ANydSYhk59fC4cvOr9ypYQLPnAj48NQIhqnjdD2yhP8yEKyBJEjERWS9DisKL7rX5eU25Q==" + }, + "System.Security.Principal.Windows": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "t0MGLukB5WAVU9bO3MGzvlGnyJPgUlcwerXn1kzBRjwLKixT96XV0Uza41W49gVd8zEMFu9vQEFlv0IOrytICA==" + }, + "ZstdSharp.Port": { + "type": "Transitive", + "resolved": "0.7.3", + "contentHash": "U9Ix4l4cl58Kzz1rJzj5hoVTjmbx1qGMwzAcbv1j/d3NzrFaESIurQyg+ow4mivCgkE3S413y+U9k4WdnEIkRA==" + } + } + } +} \ No newline at end of file diff --git a/ai/vector-search-dotnet/Program.cs b/ai/vector-search-dotnet/Program.cs index 53eaab5..3bf2abe 100644 --- a/ai/vector-search-dotnet/Program.cs +++ b/ai/vector-search-dotnet/Program.cs @@ -21,8 +21,17 @@ static async Task Main(string[] args) var appConfig = new AppConfiguration(); configuration.Bind(appConfig); + + var requiredKeys = new[] { "MONGO_CLUSTER_NAME", "AZURE_OPENAI_EMBEDDING_ENDPOINT", "AZURE_OPENAI_EMBEDDING_MODEL" }; + var missing = requiredKeys.Where(k => string.IsNullOrEmpty(configuration[k])).ToList(); + if (missing.Any()) + { + Console.Error.WriteLine($"Missing required configuration: {string.Join(", ", missing)}"); + Console.Error.WriteLine("Set as environment variables or in appsettings.json. See README for details."); + Environment.Exit(1); + } - var services = new ServiceCollection() + var services= new ServiceCollection() .AddLogging(builder => builder.AddConsole()) .AddSingleton(configuration) .AddSingleton(appConfig) @@ -30,7 +39,7 @@ static async Task Main(string[] args) .AddSingleton() .AddSingleton(); - var serviceProvider = services.BuildServiceProvider(); + await using var serviceProvider = services.BuildServiceProvider(); var logger = serviceProvider.GetRequiredService>(); try diff --git a/ai/vector-search-dotnet/Services/VectorSearchService.cs b/ai/vector-search-dotnet/Services/VectorSearchService.cs index e8505a1..9622c66 100644 --- a/ai/vector-search-dotnet/Services/VectorSearchService.cs +++ b/ai/vector-search-dotnet/Services/VectorSearchService.cs @@ -6,6 +6,7 @@ using MongoDB.Driver; using Newtonsoft.Json; using Newtonsoft.Json.Linq; +using System.Diagnostics; using System.Reflection; namespace DocumentDBVectorSamples.Services.VectorSearch; @@ -109,12 +110,16 @@ await _mongoService.CreateVectorIndexAsync( // Execute and process the search _logger.LogInformation($"Executing {indexType} vector search for top {_config.VectorSearch.TopK} results"); + var sw = Stopwatch.StartNew(); var searchResults = (await collection.AggregateAsync(searchPipeline)).ToList() .Select(result => new SearchResult { Document = MongoDB.Bson.Serialization.BsonSerializer.Deserialize(result["document"].AsBsonDocument), Score = result["score"].AsDouble }).ToList(); + sw.Stop(); + var elapsedMs = sw.ElapsedMilliseconds; + _logger.LogInformation($"Vector search completed in {elapsedMs}ms"); // Print the results if (searchResults?.Count == 0)