| File: ChatRouting\SemanticRoutingChatClient.cs | |
| Project: ..\..\..\src\Libraries\Microsoft.Extensions.AI\Microsoft.Extensions.AI.csproj (Microsoft.Extensions.AI) |
// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Numerics.Tensors; using System.Threading; using System.Threading.Tasks; using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; namespace Microsoft.Extensions.AI; /// <summary>Routes requests by semantic similarity to app-provided example utterances.</summary> /// <remarks> /// <para> /// Profile embeddings are generated lazily and cached. Each request embeds the last user message and selects the /// client with the highest score after aggregating the cosine similarities of the best-matching profile utterances. /// The configured default client is selected when no user message is available or when the highest score is below the /// configured threshold. /// </para> /// <para> /// The configured client instances are used as stable routing identities and are distinguished by reference. /// Per-call options do not participate in that identity. By default this instance owns the clients and embedding /// generator and disposes them when it is disposed. /// </para> /// <para> /// The example-utterance routing approach is inspired by /// <see href="https://github.com/aurelio-labs/semantic-router">Aurelio Labs' semantic-router project</see>. /// </para> /// </remarks> [Experimental(DiagnosticIds.Experiments.AIRoutingChat, UrlFormat = DiagnosticIds.UrlFormat)] public sealed class SemanticRoutingChatClient : RoutingChatClient { /// <summary>Specifies how profile similarity scores are aggregated for each client.</summary> public enum ScoreAggregation { /// <summary>Average the matching profile scores for each client.</summary> Mean, /// <summary>Sum the matching profile scores for each client.</summary> Sum, } private readonly IChatClient[] _clients; private readonly IEmbeddingGenerator<string, Embedding<float>> _embeddingGenerator; private readonly SemaphoreSlim _indexGate = new(1, 1); private readonly bool _leaveOpen; private readonly (int ClientIndex, string Text)[] _profiles; private readonly ScoreAggregation _scoreAggregation; private readonly float _scoreThreshold; private readonly int _topK; private bool _disposed; private EmbeddedProfile[]? _index; /// <summary>Initializes a new instance of the <see cref="SemanticRoutingChatClient"/> class.</summary> /// <param name="embeddingGenerator">The generator used to embed profile utterances and request text.</param> /// <param name="clientProfiles">The example utterances associated with each client.</param> /// <param name="defaultClient"> /// The client selected when no profile satisfies <paramref name="scoreThreshold"/>. /// </param> /// <param name="scoreThreshold">The minimum aggregated score required to select a profiled client.</param> /// <param name="topK"> /// The number of highest-scoring profile utterances, across all clients, whose scores are aggregated. /// The default is <c>1</c>. /// </param> /// <param name="scoreAggregation">The method used to aggregate matching profile scores for each client.</param> /// <param name="leaveOpen"> /// <see langword="true"/> to leave the configured clients and embedding generator open when this instance is /// disposed; otherwise, <see langword="false"/>. The default is <see langword="false"/>. /// </param> /// <exception cref="ArgumentNullException"> /// <paramref name="embeddingGenerator"/>, <paramref name="clientProfiles"/>, or /// <paramref name="defaultClient"/> is <see langword="null"/>. /// </exception> /// <exception cref="ArgumentException"> /// <paramref name="clientProfiles"/> is empty or contains a null client, an empty utterance list, or a blank /// utterance. /// </exception> /// <exception cref="ArgumentOutOfRangeException"> /// <paramref name="topK"/> is not positive, <paramref name="scoreAggregation"/> is invalid, or /// <paramref name="scoreThreshold"/> is outside the possible range for the configured aggregation. /// </exception> public SemanticRoutingChatClient( IEmbeddingGenerator<string, Embedding<float>> embeddingGenerator, IReadOnlyDictionary<IChatClient, IReadOnlyList<string>> clientProfiles, IChatClient defaultClient, float scoreThreshold = 0.3f, int topK = 1, ScoreAggregation scoreAggregation = ScoreAggregation.Mean, bool leaveOpen = false) { _ = Throw.IfNull(embeddingGenerator); _ = Throw.IfNull(clientProfiles); _ = Throw.IfNull(defaultClient); if (clientProfiles.Count == 0) { Throw.ArgumentException(nameof(clientProfiles), "At least one client profile must be provided."); } if (topK <= 0) { Throw.ArgumentOutOfRangeException(nameof(topK)); } if (scoreAggregation is not ScoreAggregation.Mean and not ScoreAggregation.Sum) { Throw.ArgumentOutOfRangeException(nameof(scoreAggregation)); } float scoreLimit = scoreAggregation == ScoreAggregation.Sum ? topK : 1; if (float.IsNaN(scoreThreshold) || float.IsInfinity(scoreThreshold) || scoreThreshold < -scoreLimit || scoreThreshold > scoreLimit) { Throw.ArgumentOutOfRangeException(nameof(scoreThreshold)); } _embeddingGenerator = embeddingGenerator; _leaveOpen = leaveOpen; _topK = topK; _scoreAggregation = scoreAggregation; _scoreThreshold = scoreThreshold; var profiles = new List<(int ClientIndex, string Text)>(); var clients = new List<IChatClient> { defaultClient }; foreach (KeyValuePair<IChatClient, IReadOnlyList<string>> profile in clientProfiles) { IChatClient client = profile.Key; IReadOnlyList<string> utterances = profile.Value; if (client is null) { Throw.ArgumentException(nameof(clientProfiles), "Profile clients must not be null."); } if (utterances is null || utterances.Count == 0) { Throw.ArgumentException( nameof(clientProfiles), "Every profile client must have at least one example utterance."); } int clientIndex = clients.FindIndex(candidate => ReferenceEquals(candidate, client)); if (clientIndex < 0) { clientIndex = clients.Count; clients.Add(client); } foreach (string utterance in utterances) { if (string.IsNullOrWhiteSpace(utterance)) { Throw.ArgumentException(nameof(clientProfiles), "Profile utterances must not be blank."); } profiles.Add((clientIndex, utterance)); } } _clients = [.. clients]; _profiles = [.. profiles]; } /// <inheritdoc/> protected override async ValueTask<IChatClient> SelectClientAsync( RoutingContext context, CancellationToken cancellationToken) { _ = Throw.IfNull(context); string? query = context.Messages.LastOrDefault( static message => message.Role == ChatRole.User)?.Text; if (string.IsNullOrWhiteSpace(query)) { return _clients[0]; } EmbeddedProfile[] index = await EnsureIndexAsync(cancellationToken).ConfigureAwait(false); GeneratedEmbeddings<Embedding<float>> generated = await _embeddingGenerator.GenerateAsync( [query!], cancellationToken: cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException("The embedding generator returned null."); if (generated.Count != 1) { throw new InvalidOperationException("The embedding generator did not return one query embedding."); } ReadOnlySpan<float> queryVector = generated[0].Vector.Span; if (queryVector.Length != index[0].Vector.Length) { throw new InvalidOperationException( "The query embedding dimension does not match the profile embedding dimension."); } if (_topK == 1) { int bestClientIndex = -1; float bestScore = float.NegativeInfinity; foreach (EmbeddedProfile profile in index) { float score = TensorPrimitives.CosineSimilarity(queryVector, profile.Vector); if (score > bestScore) { bestClientIndex = profile.ClientIndex; bestScore = score; } } return bestClientIndex >= 0 && bestScore >= _scoreThreshold ? _clients[bestClientIndex] : _clients[0]; } var matches = new ScoredProfile[index.Length]; for (int i = 0; i < index.Length; i++) { matches[i] = new( i, TensorPrimitives.CosineSimilarity(queryVector, index[i].Vector)); } Array.Sort(matches, static (left, right) => { int scoreComparison = right.Score.CompareTo(left.Score); return scoreComparison != 0 ? scoreComparison : left.ProfileIndex.CompareTo(right.ProfileIndex); }); int matchCount = Math.Min(_topK, matches.Length); var scoreSums = new float[_clients.Length]; var scoreCounts = new int[_clients.Length]; var clientOrder = new int[_clients.Length]; int clientCount = 0; for (int i = 0; i < matchCount; i++) { EmbeddedProfile profile = index[matches[i].ProfileIndex]; int clientIndex = profile.ClientIndex; if (scoreCounts[clientIndex] == 0) { clientOrder[clientCount++] = clientIndex; } scoreSums[clientIndex] += matches[i].Score; scoreCounts[clientIndex]++; } int bestAggregatedClientIndex = -1; float bestAggregatedScore = float.NegativeInfinity; for (int i = 0; i < clientCount; i++) { int clientIndex = clientOrder[i]; float score = _scoreAggregation == ScoreAggregation.Mean ? scoreSums[clientIndex] / scoreCounts[clientIndex] : scoreSums[clientIndex]; if (score > bestAggregatedScore) { bestAggregatedClientIndex = clientIndex; bestAggregatedScore = score; } } return bestAggregatedClientIndex >= 0 && bestAggregatedScore >= _scoreThreshold ? _clients[bestAggregatedClientIndex] : _clients[0]; } /// <inheritdoc/> protected override void Dispose(bool disposing) { if (_disposed) { return; } _disposed = true; if (disposing) { _indexGate.Dispose(); if (!_leaveOpen) { foreach (IChatClient client in _clients) { client.Dispose(); } _embeddingGenerator.Dispose(); } } base.Dispose(disposing); } private async Task<EmbeddedProfile[]> EnsureIndexAsync(CancellationToken cancellationToken) { if (_index is { } cached) { return cached; } await _indexGate.WaitAsync(cancellationToken).ConfigureAwait(false); try { if (_index is { } existing) { return existing; } GeneratedEmbeddings<Embedding<float>> embeddings = await _embeddingGenerator.GenerateAsync( _profiles.Select(profile => profile.Text), cancellationToken: cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException("The embedding generator returned null."); if (embeddings.Count != _profiles.Length) { throw new InvalidOperationException( "The embedding generator did not return one embedding per profile utterance."); } int dimensions = embeddings[0].Vector.Length; if (dimensions == 0) { throw new InvalidOperationException("Profile embeddings must not be empty."); } var index = new EmbeddedProfile[_profiles.Length]; for (int i = 0; i < index.Length; i++) { if (embeddings[i].Vector.Length != dimensions) { throw new InvalidOperationException( "All profile embeddings must have the same dimension."); } index[i] = new(_profiles[i].ClientIndex, embeddings[i].Vector.ToArray()); } return _index = index; } finally { _ = _indexGate.Release(); } } private sealed record EmbeddedProfile(int ClientIndex, float[] Vector); private readonly record struct ScoredProfile(int ProfileIndex, float Score); }