cool-solution — dev.blog
Tecnologie

Semantic caching per gli LLM in .NET con Redis e Ollama

Hai mai pensato di usare la RAG per cercare tra i prompt e le risposte già date, invece che solo tra i soliti documenti? È esattamente l'idea del semantic caching: gli stessi mattoni della RAG — embedding e ricerca vettoriale — applicati al tuo storico di domande e risposte, con una differenza: quando c'è un hit salti del tutto la generazione. Ogni chiamata a un LLM costa: tempo e, sul cloud, token. Ma nella pratica gli utenti fanno spesso la stessa domanda — a volte identica, più spesso riformulata. Qui costruisco una cache a due livelli davanti al modello: un primo strato a match esatto con il middleware UseDistributedCache di Microsoft.Extensions.AI, e un secondo strato semantico che confronta gli embedding con una ricerca vettoriale su Redis 8. Risultato: le risposte ripetute e quelle equivalenti arrivano in millisecondi, senza toccare il modello. Tutto in locale con Ollama: nessuna chiave API cloud e nessun costo a token.

Schema del semantic caching per LLM: un prompt viene controllato prima da una cache a match esatto su Redis, poi da una cache semantica che confronta gli embedding con KNN, e solo un vero miss raggiunge il modello Ollama, palette Cool Solution.

🧠 Che cos'è il semantic caching

Il semantic caching è una cache che non ragiona per chiave esatta ma per significato: se una domanda è semanticamente vicina a una già vista, restituisce la risposta salvata invece di rieseguire il modello. È la differenza tra «Cos'è Redis?» e «Mi spieghi cos'è Redis?»: per un LLM sono la stessa domanda, per una cache tradizionale no.

L'idea è semplice. Trasformo la domanda in un vettore — l'embedding — e cerco nel mio archivio il vettore più vicino. Se la distanza è sotto una soglia è un hit: rispondo dalla cache. Altrimenti chiamo il modello, salvo la risposta con il suo embedding e la rendo disponibile per le volte successive.

Semantic caching in pseudocodice
# a ogni domanda
q      = embed(question)          # vettore della domanda
hit    = vector_search(q, top=1)  # il piu' vicino in cache
if hit.distance <= soglia:
    return hit.answer             # cache hit: niente modello

answer = llm(question)            # cache miss
store(q, question, answer)        # salva per la prossima volta
return answer
Embedda la domanda, cerca il vicino piu' prossimo, rispondi dalla cache o chiama il modello.

🎯 Quando conviene (e quando no)

Il caso d'uso perfetto è un flusso con domande ricorrenti: assistenti su una documentazione, FAQ di prodotto, chatbot di supporto, ricerche interne. Lì la stessa manciata di domande torna di continuo, con mille varianti di forma: la cache semantica intercetta le riformulazioni e taglia latenza e costo per richiesta.

Non è gratis e non va bene per tutto. Va evitata — o gestita con cura — quando la risposta dipende dall'utente, dal momento o da dati che cambiano (saldi, meteo, stato di un ordine). Due leve tengono sotto controllo il rischio del livello semantico: una soglia di similarità non troppo permissiva e un TTL che fa scadere le voci semantiche. Meglio un miss in più che una risposta sbagliata perché troppo «vicina».

  • Ideale: FAQ, documentazione, supporto, domande che si ripetono.
  • Da evitare: risposte personalizzate, dati in tempo reale, contenuti che cambiano spesso.
  • Due manopole semantiche: soglia di distanza (quanto «vicino» è un hit) e TTL (quanto vive una voce semantica).
Dove finisce il tempo
Confronto di latenza a barre orizzontali: un cache miss esegue il modello completo llama3.1 e impiega dell'ordine dei secondi; un hit di match esatto risponde in pochi millisecondi; un hit semantico, che paga un embedding piu' una ricerca KNN su Redis, risponde in decine di millisecondi.

Numeri indicativi: un miss paga l'inferenza piena, un hit paga pochi millisecondi.

🧩 Due livelli: cache esatta e cache semantica

Il livello esterno a match esatto è UseDistributedCache di Microsoft.Extensions.AI: calcola l'hash dei messaggi della conversazione e serve una richiesta identica direttamente da Redis, senza generare un embedding. Solo un miss esatto raggiunge il mio livello semantico.

Il SemanticCachingChatClient su misura genera l'embedding dell'ultimo prompt utente ed esegue una ricerca KNN nell'indice vettoriale Redis. Un vicino sotto MaxDistance restituisce la risposta salvata; un vero miss raggiunge Ollama e viene poi memorizzato. In questa demo Ttl fa scadere solo gli hash semantici: le voci a match esatto create da UseDistributedCache non hanno una scadenza configurata.

Match esatto vs. semantico

Cache esatta

  • Hash dei messaggi
  • Solo prompt identici
  • Nessun embedding, latenza minima
  • UseDistributedCache, integrato

Cache semantica

  • Embedding + ricerca vettoriale
  • Cattura le riformulazioni
  • Un embedding per richiesta
  • Middleware su misura (DelegatingChatClient)

I prompt identici si fermano al livello esatto; le riformulazioni possono fermarsi al semantico; solo un vero miss raggiunge Ollama.

🏗️ L'architettura della soluzione

Costruisco una Minimal API in .NET 10. Il cuore è l'astrazione IChatClient di Microsoft.Extensions.AI, avvolta in una pipeline di middleware: prima UseDistributedCache (match esatto), poi il mio SemanticCachingChatClient (semantico), infine il client Ollama che parla col modello. I due livelli di cache condividono una sola dipendenza di caching: Redis 8.

La cosa elegante è che il resto dell'app non sa nulla di tutto questo: dipende solo da IChatClient. Passare da Ollama a OpenAI o Azure richiede di cambiare le registrazioni sia della chat sia degli embedding; se cambiano le dimensioni degli embedding devo anche ricreare cache_idx. I middleware di cache possono restare invariati. Redis fa doppio lavoro: cache distribuita per il match esatto e indice vettoriale per quello semantico.

  • Modelli: Ollama con llama3.1 (chat) e nomic-embed-text (embedding, 768 dimensioni).
  • Cache + vettori: Redis 8, che integra il Query Engine (niente più Redis Stack separato).
  • Pacchetti: Microsoft.Extensions.AI, OllamaSharp, NRedisStack, Microsoft.Extensions.Caching.StackExchangeRedis.
Architettura: una cache a due livelli nella pipeline
Architettura della pipeline: POST /chat risolve un IChatClient costruito come catena di middleware — UseDistributedCache per il match esatto, poi il SemanticCachingChatClient che embedda il prompt ed esegue una ricerca KNN, infine il modello Ollama; Redis 8 sta dietro sia alla cache distribuita sia all'indice vettoriale, e una sola extension method AddSemanticCache registra tutto.

Un IChatClient, tre stadi di middleware, un solo Redis dietro.

⬇️ Passo 1 · Ollama e i due modelli

Ollama fa girare i modelli in locale ed espone le API su localhost:11434. Mi servono due modelli: uno di chat (llama3.1) e uno di embedding (nomic-embed-text, 768 dimensioni). Il numero di dimensioni conta: deve combaciare con l'indice vettoriale di Redis.

Se preferisco Docker li scarico direttamente nel container (lo vediamo al passo successivo). Da riga di comando bastano due pull.

Scarica i modelli con Ollama
# macOS: brew install ollama --- Linux: curl -fsSL https://ollama.com/install.sh | sh
$ ollama pull llama3.1            # modello di chat
$ ollama pull nomic-embed-text   # embedding (768 dimensioni)

# prova a generare un embedding
$ curl http://localhost:11434/api/embed \
    -d '{"model":"nomic-embed-text","input":"ciao"}'

Download · Ollama

Due modelli: uno per la chat, uno per gli embedding. Le dimensioni (768) contano.

🐳 Passo 2 · Redis 8, Ollama e MongoDB con Docker Compose

Metto le dipendenze in Docker Compose: Redis 8, Ollama e — per la sezione bonus sull'output caching — MongoDB. Da Redis 8 il Query Engine (ricerca vettoriale e full-text) è integrato nell'immagine core: non serve più il vecchio Redis Stack. Un servizio one-shot scarica i modelli appena Ollama è pronto.

L'app gira sull'host con dotnet run e parla con i container su localhost. docker compose up -d avvia in background Redis, Ollama, MongoDB e il servizio one-shot che scarica i modelli. Prima di avviare l'API eseguo docker compose logs -f ollama-init e aspetto che il servizio termini con codice 0: la modalità detached non aspetta la fine dei download.

docker-compose.yml
name: semantic-caching-llm-dotnet-redis

# External dependencies only: the ASP.NET Core app runs on the host with
# `dotnet run` and talks to these services on localhost. `docker compose up -d`
# starts Ollama, Redis 8 and MongoDB, and pulls the two models into the Ollama
# volume. MongoDB uses host port 27018 to avoid the common local 27017 conflict.
services:
  ollama:
    image: ollama/ollama:latest
    container_name: semantic-cache-ollama
    ports:
      - "11434:11434"
    volumes:
      - ollama-data:/root/.ollama
    healthcheck:
      test: ["CMD-SHELL", "ollama list >/dev/null 2>&1 || exit 1"]
      interval: 10s
      timeout: 5s
      retries: 15
    restart: unless-stopped

  # One-shot init: waits for Ollama to be healthy, pulls both models, then exits.
  ollama-init:
    image: ollama/ollama:latest
    depends_on:
      ollama:
        condition: service_healthy
    environment:
      - OLLAMA_HOST=ollama:11434
    entrypoint: ["/bin/sh", "-c", "ollama pull llama3.1 && ollama pull nomic-embed-text"]
    restart: "no"

  redis:
    image: redis:8
    container_name: semantic-cache-redis
    ports:
      - "6379:6379"
    volumes:
      - redis-data:/data
    command: ["redis-server", "--save", "60", "1"]
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 5s
      retries: 15
    restart: unless-stopped

  # Backs the /users API: the "pocsemantic-caching-llm" database is created on
  # first write; indexes + Bogus seeding happen in the app's startup job.
  mongodb:
    image: mongo:8
    container_name: semantic-cache-mongodb
    ports:
      - "27018:27017"
    volumes:
      - mongo-data:/data/db
    healthcheck:
      test: ["CMD-SHELL", "mongosh --quiet --eval 'db.runCommand({ ping: 1 }).ok' | grep -q 1"]
      interval: 10s
      timeout: 5s
      retries: 15
    restart: unless-stopped

volumes:
  ollama-data:
  redis-data:
  mongo-data:

Redis 8 · Docs

Un comando: Redis 8, Ollama, MongoDB e il download dei modelli. L'app resta sull'host.

🧱 Passo 3 · Il progetto e i pacchetti NuGet

Creo una Minimal API e aggiungo quattro pacchetti per il flusso principale di semantic caching: le astrazioni Microsoft.Extensions.AI (che portano anche i middleware UseDistributedCache e UseLogging), OllamaSharp per parlare con Ollama, NRedisStack per l'indice vettoriale e il pacchetto di cache distribuita su Redis. Il bonus MongoDB/output cache richiede anche MongoDB.Driver, Bogus, FluentValidation.DependencyInjectionExtensions e Microsoft.AspNetCore.OutputCaching.StackExchangeRedis.

Una nota su Ollama: il vecchio pacchetto Microsoft.Extensions.AI.Ollama è deprecato. Oggi si usa OllamaSharp, il cui OllamaApiClient implementa sia IChatClient sia IEmbeddingGenerator: le stesse interfacce di qualsiasi provider cloud.

Crea il progetto e aggiungi i pacchetti
$ dotnet new web -n SemanticCacheDemo && cd SemanticCacheDemo

$ dotnet add package Microsoft.Extensions.AI
$ dotnet add package Microsoft.Extensions.Caching.StackExchangeRedis
$ dotnet add package NRedisStack
$ dotnet add package OllamaSharp

# Bonus: API utenti MongoDB + validazione + output cache Redis
$ dotnet add package MongoDB.Driver
$ dotnet add package Bogus
$ dotnet add package FluentValidation.DependencyInjectionExtensions
$ dotnet add package Microsoft.AspNetCore.OutputCaching.StackExchangeRedis

Microsoft.Extensions.AI · Microsoft Learn

Prima i pacchetti core del semantic caching, poi i quattro usati dal bonus MongoDB/output cache.

⚙️ Passo 4 · La configurazione in appsettings.json

Tutta la configurazione vive in appsettings.json: l'endpoint di Ollama, i due modelli, le dimensioni dell'embedding, la connessione a Redis e — le due manopole del semantico — la distanza massima per un hit e il TTL delle voci semantiche.

Tenendo qui questi valori cambio soglia, modello o host senza toccare il codice.

appsettings.json
{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*",
  "SemanticCache": {
    "OllamaEndpoint": "http://localhost:11434",
    "ChatModel": "llama3.1",
    "EmbeddingModel": "nomic-embed-text",
    "EmbeddingDimensions": 768,
    "RedisConnection": "localhost:6379",
    "IndexName": "cache_idx",
    "KeyPrefix": "cache:",
    "MaxDistance": 0.15,
    "Ttl": "1.00:00:00"
  },
  "Mongo": {
    "ConnectionString": "mongodb://localhost:27018",
    "DatabaseName": "pocsemantic-caching-llm",
    "EnableLogQueries": false
  }
}
Le due manopole semantiche: MaxDistance (quanto vicino è un hit) e Ttl (quanto vive una voce semantica).

🗂️ Passo 5 · Le opzioni tipizzate

Lego quella sezione a una classe fortemente tipizzata, SemanticCacheOptions, così il resto del codice non usa mai stringhe magiche. Un dettaglio importante: EmbeddingDimensions deve combaciare col modello (768 per nomic-embed-text); se cambio embedder, ricostruisco l'indice con le nuove dimensioni.

SemanticCacheOptions.cs
namespace SemanticCacheDemo.Configuration;

// Strongly-typed configuration bound from the "SemanticCache" section of
// appsettings.json. Keeping every knob here means changing model, host or
// threshold never touches the code.
public sealed class SemanticCacheOptions
{
    public const string SectionName = "SemanticCache";

    // Ollama runs both the chat model and the embedding model, locally.
    public string OllamaEndpoint { get; init; } = "http://localhost:11434";
    public string ChatModel { get; init; } = "llama3.1";
    public string EmbeddingModel { get; init; } = "nomic-embed-text";

    // Must match the embedding model: nomic-embed-text returns 768 dimensions.
    public int EmbeddingDimensions { get; init; } = 768;

    // Redis 8 backs both the exact-match distributed cache and the semantic index.
    public string RedisConnection { get; init; } = "localhost:6379";
    public string IndexName { get; init; } = "cache_idx";
    public string KeyPrefix { get; init; } = "cache:";

    // Max cosine distance (0 = identical, 2 = opposite) accepted as a semantic hit.
    // Lower is stricter: 0.15 is a sensible starting point, tune it on your corpus.
    public double MaxDistance { get; init; } = 0.15;

    // How long a semantic-cache entry stays valid before Redis expires it.
    // The exact-match distributed-cache layer has its own independent policy.
    public TimeSpan Ttl { get; init; } = TimeSpan.FromDays(1);
}
Ogni parametro in un posto solo: modelli, Redis, soglia di distanza e TTL delle voci semantiche.

🔑 Passo 6 · Lo store: indice vettoriale e ricerca KNN su Redis

Il SemanticCacheStore incapsula Redis. Fa tre cose: crea l'indice vettoriale una volta (idempotente), esegue la ricerca KNN del vicino più prossimo e salva le nuove coppie domanda/risposta come hash che l'indice raccoglie da solo.

L'indice usa HNSW, tipo FLOAT32 e metrica coseno. La query KNN riporta la distanza (0 = identico): se è sotto la soglia, restituisco la risposta salvata. Il vettore lo passo come byte in little-endian, esattamente il layout che Redis si aspetta.

SemanticCacheStore.cs
using System.Globalization;
using System.Runtime.InteropServices;
using Microsoft.Extensions.Options;
using NRedisStack.RedisStackCommands;
using NRedisStack.Search;
using NRedisStack.Search.Literals.Enums;
using SemanticCacheDemo.Configuration;
using StackExchange.Redis;

namespace SemanticCacheDemo.Caching;

// Wraps Redis 8 (the Query Engine is built into the core image, no separate
// Redis Stack module) as a semantic cache: it creates the vector index once,
// runs KNN look-ups and persists new prompt/answer pairs.
public sealed class SemanticCacheStore(IConnectionMultiplexer redis, IOptions<SemanticCacheOptions> options)
    : ISemanticCacheStore
{
    private readonly IDatabase _db = redis.GetDatabase();
    private readonly SemanticCacheOptions _opt = options.Value;

    // Idempotent: if the index already exists we leave it untouched.
    public async Task EnsureIndexAsync()
    {
        var indexes = (RedisResult[]?)await _db.ExecuteAsync("FT._LIST") ?? [];
        if (indexes.Any(index => index.ToString() == _opt.IndexName))
            return;

        var schema = new Schema()
            .AddVectorField("embedding", Schema.VectorField.VectorAlgo.HNSW, new Dictionary<string, object>
            {
                ["TYPE"] = "FLOAT32",
                ["DIM"] = _opt.EmbeddingDimensions,
                ["DISTANCE_METRIC"] = "COSINE",
            })
            .AddTextField("prompt")
            .AddTextField("answer");

        var parameters = new FTCreateParams().On(IndexDataType.HASH).Prefix(_opt.KeyPrefix);
        await _db.FT().CreateAsync(_opt.IndexName, parameters, schema);
    }

    // KNN=1 look-up. Returns the cached answer when the nearest neighbour is within
    // the configured cosine distance, otherwise null (a cache miss).
    public async Task<string?> FindAsync(ReadOnlyMemory<float> embedding)
    {
        var query = new Query("*=>[KNN 1 @embedding $vec AS score]")
            .AddParam("vec", ToBytes(embedding))
            .SetSortBy("score")
            .ReturnFields("answer", "score")
            .Limit(0, 1)
            .Dialect(2);

        var result = await _db.FT().SearchAsync(_opt.IndexName, query);
        var match = result.Documents.FirstOrDefault();
        if (match is null)
            return null;

        var distance = double.Parse(match["score"].ToString(), CultureInfo.InvariantCulture);
        return distance <= _opt.MaxDistance ? match["answer"].ToString() : null;
    }

    // Persists a prompt/answer pair as a Redis hash the index picks up automatically.
    public async Task SaveAsync(ReadOnlyMemory<float> embedding, string prompt, string answer)
    {
        var key = $"{_opt.KeyPrefix}{Guid.NewGuid():N}";
        await _db.HashSetAsync(key,
        [
            new HashEntry("embedding", ToBytes(embedding)),
            new HashEntry("prompt", prompt),
            new HashEntry("answer", answer),
        ]);

        if (_opt.Ttl > TimeSpan.Zero)
            await _db.KeyExpireAsync(key, _opt.Ttl);
    }

    // FLOAT32 little-endian is exactly the byte layout the Redis vector index expects.
    private static byte[] ToBytes(ReadOnlyMemory<float> vector) =>
        MemoryMarshal.AsBytes(vector.Span).ToArray();
}

Vector search · Redis Docs

HNSW, FLOAT32, coseno: crea l'indice, cerca il vicino più prossimo, salva la coppia.

♻️ Passo 7 · Il middleware di cache semantica

Il SemanticCachingChatClient è il pezzo forte: eredita da DelegatingChatClient, quindi è un IChatClient che ne avvolge un altro e si incastra con qualsiasi altro middleware di Microsoft.Extensions.AI (cache distribuita, logging, function invocation, telemetria).

La logica è lineare. Per la richiesta stateless usata da /chat — un solo messaggio utente e nessun ChatOptions — genera l'embedding e interroga lo store. Le richieste con system prompt, cronologia, tool o opzioni del provider saltano la cache semantica, perché questi input possono cambiare la risposta anche con l'ultimo prompt identico. Su un miss semantico chiama il client interno e salva la risposta fresca.

SemanticCachingChatClient.cs
using Microsoft.Extensions.AI;
using SemanticCacheDemo.Mappings;

namespace SemanticCacheDemo.Caching;

// A drop-in IChatClient middleware. Before hitting the model it embeds the prompt
// and looks for a semantically-close answer already in Redis. On a miss it calls
// the inner client and stores the fresh answer for next time. Semantic caching is
// deliberately limited to one user message with no ChatOptions: system prompts,
// history, tools and provider options can change the answer and must not share a
// cache entry based only on the last prompt. Contextual requests pass through.
public sealed class SemanticCachingChatClient(
    IChatClient inner,
    IEmbeddingGenerator<string, Embedding<float>> embedder,
    ISemanticCacheStore store)
    : DelegatingChatClient(inner)
{
    // Serves the last user message from the semantic cache when a close-enough answer
    // exists, otherwise delegates to the inner client and stores the fresh answer.
    public override async Task<ChatResponse> GetResponseAsync(
        IEnumerable<ChatMessage> messages,
        ChatOptions? options = null,
        CancellationToken cancellationToken = default)
    {
        // 1) Guard: this demo's semantic key is only the prompt embedding. Restrict it
        //    to the stateless shape used by POST /chat so different system prompts,
        //    histories, tools or generation options can never collide semantically.
        var messageList = messages as IReadOnlyList<ChatMessage> ?? messages.ToList();
        var prompt = options is null && messageList.Count == 1 && messageList[0].Role == ChatRole.User
            ? messageList[0].Text
            : null;

        if (string.IsNullOrWhiteSpace(prompt))
            return await base.GetResponseAsync(messageList, options, cancellationToken);

        // 2) Embed the prompt and probe the vector store for a semantic neighbour.
        var embedding = await embedder.GenerateVectorAsync(prompt, cancellationToken: cancellationToken);

        var cached = await store.FindAsync(embedding);
        if (cached is not null)
            return cached.ToSemanticCacheResponse();

        // 3) Miss: call the model, then persist the pair so the next close prompt hits.
        var response = await base.GetResponseAsync(messageList, options, cancellationToken);
        await store.SaveAsync(embedding, prompt, response.Text);
        return response;
    }
}

DelegatingChatClient · Microsoft Learn

Le richieste stateless usano la cache semantica; quelle contestuali o con opzioni passano oltre in sicurezza.

🔌 Passo 8 · Registrare la pipeline con AddSemanticCache

Raccolgo tutto il wiring in una extension method, AddSemanticCache. Registra Redis (condiviso da cache distribuita e store), il generatore di embedding di Ollama e — cuore di tutto — la pipeline del chat client nell'ordine giusto.

L'ordine conta: dall'esterno verso l'interno è match esatto → semantico → Ollama. Una domanda identica non raggiunge mai il livello semantico; una riformulazione salta l'esatto e viene presa dal vettoriale; solo un vero miss arriva al modello.

ServiceCollectionExtensions.cs
using Microsoft.Extensions.AI;
using OllamaSharp;
using SemanticCacheDemo.Caching;
using SemanticCacheDemo.Configuration;
using StackExchange.Redis;

namespace SemanticCacheDemo.Extensions;

public static class ServiceCollectionExtensions
{
    // One call wires the whole two-layer cache: Ollama (chat + embeddings), the
    // Redis exact-match distributed cache and the semantic cache middleware.
    public static IServiceCollection AddSemanticCache(this IServiceCollection services, IConfiguration configuration)
    {
        var section = configuration.GetSection(SemanticCacheOptions.SectionName);
        services.Configure<SemanticCacheOptions>(section);
        var options = section.Get<SemanticCacheOptions>() ?? new SemanticCacheOptions();

        // Redis 8, shared by the exact-match distributed cache and the semantic store.
        services.AddSingleton<IConnectionMultiplexer>(_ => ConnectionMultiplexer.Connect(options.RedisConnection));
        services.AddStackExchangeRedisCache(redis => redis.Configuration = options.RedisConnection);
        services.AddSingleton<ISemanticCacheStore, SemanticCacheStore>();

        var endpoint = new Uri(options.OllamaEndpoint);

        // Embeddings: a dedicated Ollama client bound to the embedding model.
        services.AddEmbeddingGenerator(new OllamaApiClient(endpoint) { SelectedModel = options.EmbeddingModel });

        // Chat pipeline, outermost first: exact-match cache -> semantic cache -> Ollama.
        // An identical prompt never reaches the semantic layer; a paraphrase skips the
        // exact layer and is caught by the vector search; only a true miss hits the model.
        services.AddChatClient(new OllamaApiClient(endpoint) { SelectedModel = options.ChatModel })
            .UseDistributedCache()
            .Use((innerClient, provider) => new SemanticCachingChatClient(
                innerClient,
                provider.GetRequiredService<IEmbeddingGenerator<string, Embedding<float>>>(),
                provider.GetRequiredService<ISemanticCacheStore>()))
            .UseLogging();

        return services;
    }
}

IChatClient middleware · Microsoft Learn

Un'unica AddSemanticCache: Redis, embedding e la pipeline esatta → semantica → modello.

🚀 Passo 9 · Program.cs: wiring e indice allo startup

Il Program.cs resta compatto: una chiamata ad AddSemanticCache, la creazione dell'indice Redis e il completamento di indici/seed MongoDB prima che Kestrel accetti richieste. Aspettare il seed evita di mettere in cache una prima risposta /users vuota.

Program.cs
using FluentValidation;
using SemanticCacheDemo.Caching;
using SemanticCacheDemo.Configuration;
using SemanticCacheDemo.Endpoints;
using SemanticCacheDemo.Extensions;
using SemanticCacheDemo.HostedServices;

var builder = WebApplication.CreateBuilder(args);

// LLM side: Ollama + the two-layer cache (exact match + semantic) on Redis.
builder.Services.AddSemanticCache(builder.Configuration);

// Data side: MongoDB (single database, generic IRepository<>), user service,
// FluentValidation and the startup job that ensures indexes + seeds fake users.
builder.Services.AddMongoInfrastructure(builder.Configuration);
builder.Services.AddValidatorsFromAssemblyContaining<Program>();

// OUTPUT caching on the same Redis: whole HTTP responses, tagged for eviction.
var redisConnection = builder.Configuration
    .GetSection(SemanticCacheOptions.SectionName)[nameof(SemanticCacheOptions.RedisConnection)]!;
builder.Services.AddStackExchangeRedisOutputCache(options => options.Configuration = redisConnection);
builder.Services.AddOutputCache();

var app = builder.Build();

// Create the Redis vector index once, at startup (idempotent).
await app.Services.GetRequiredService<ISemanticCacheStore>().EnsureIndexAsync();

// Finish MongoDB indexes and seed before the server can answer and cache /users.
await app.Services.GetRequiredService<UserCollectionInitializer>()
    .InitializeAsync(app.Lifetime.ApplicationStopping);

app.UseOutputCache();

app.MapChatEndpoints();   // POST /chat          -> two-layer LLM cache
app.MapUserEndpoints();   // GET /users          -> Mongo + Redis output cache
app.MapCacheEndpoints();  // DELETE /cache/output -> flush the output cache

app.Run();
Inizializzazione Redis e MongoDB completata prima di output caching e route dell'API.

📡 Passo 10 · L'endpoint /chat

Un solo endpoint, POST /chat. FluentValidation rifiuta un prompt mancante o vuoto con HTTP 400 prima di toccare Ollama; le richieste valide chiamano il chat client già avvolto dalle due cache e restituiscono un DTO tipizzato con risposta, fonte e tempo trascorso.

La fonte vale semantic-cache quando è il livello vettoriale a rispondere, altrimenti model. Il match esatto è trasparente: non marca la risposta, ma lo si vede benissimo dal crollo dei millisecondi quando ripeto una domanda identica.

ChatEndpoints.cs
using System.Diagnostics;
using FluentValidation;
using Microsoft.Extensions.AI;
using SemanticCacheDemo.Mappings;

namespace SemanticCacheDemo.Endpoints;

// Typed request/response contracts (no anonymous objects on the wire).
public sealed record ChatRequest(string Prompt);
public sealed record ChatReply(string Answer, string Source, long ElapsedMs);

public static class ChatEndpoints
{
    // POST /chat -> ask the cached chat client. "source" is "semantic-cache" when the
    // vector layer served the answer, otherwise "model"; the exact-match layer is
    // transparent, so watch elapsedMs collapse when you repeat an identical prompt.
    public static void MapChatEndpoints(this IEndpointRouteBuilder app) =>
        app.MapPost("/chat", async (
            ChatRequest request,
            IValidator<ChatRequest> validator,
            IChatClient chat,
            CancellationToken ct) =>
        {
            var validation = await validator.ValidateAsync(request, ct);
            if (!validation.IsValid)
                return Results.ValidationProblem(validation.ToDictionary());

            // Time the whole call so the reply can expose how much the cache saved.
            var stopwatch = Stopwatch.StartNew();
            var response = await chat.GetResponseAsync(request.Prompt, cancellationToken: ct);
            stopwatch.Stop();

            // The mapping resolves "source" from the marker the caching layer stamped.
            return Results.Ok(response.ToChatReply(stopwatch.ElapsedMilliseconds));
        });
}
POST /chat valida prima, poi restituisce risposta, fonte e millisecondi.

🧪 Passo 11 · Provarlo da terminale

Quando il pull dei modelli è finito, dalla cartella SemanticCacheDemo avvio l'app con `dotnet run --urls http://localhost:5050`. Poi tre curl raccontano la storia: la prima domanda è un miss del modello, la stessa è un hit esatto e una riformulazione viene presa dal livello semantico.

Prova la cache con curl
# 1) primo giro: il modello lavora (source: model, lento)
$ curl -s localhost:5050/chat -H "Content-Type: application/json" \
    -d '{"prompt":"Cosa fa Redis, in breve?"}'

# 2) stessa identica domanda: match esatto (elapsedMs crolla)
$ curl -s localhost:5050/chat -H "Content-Type: application/json" \
    -d '{"prompt":"Cosa fa Redis, in breve?"}'

# 3) riformulazione: cache semantica (source: semantic-cache)
$ curl -s localhost:5050/chat -H "Content-Type: application/json" \
    -d '{"prompt":"In breve, a cosa serve Redis?"}'
Carico, ripeto, riformulo: model, poi match esatto, poi semantico.

📈 Le tre risposte a confronto

Ecco le tre risposte affiancate. La stessa risposta, tre costi diversissimi: il primo giro paga l'inferenza piena; il secondo è un GET su Redis; il terzo paga solo un embedding e una ricerca KNN. Nessuno dei due hit tocca il modello.

Model, match esatto, semantico
Sessione di terminale con tre POST verso localhost:5050/chat: il primo prompt restituisce source model con elapsedMs intorno a 4218; lo stesso prompt ripetuto torna quasi istantaneo con elapsedMs molto piu' basso; un prompt riformulato restituisce source semantic-cache con un piccolo elapsedMs.

Stessa risposta, tre costi: model (4218 ms), match esatto (pochi ms), semantico (decine di ms).

✅ Checklist finale

Ricapitolo. Se hai seguito i passi, ora hai una Minimal API con una cache a due livelli davanti a un LLM locale: le domande ripetute e quelle riformulate costano millisecondi, non secondi.

Da qui puoi salire di livello: metriche sul rapporto di hit, una soglia adattiva, il warming della cache con le domande più frequenti, o spostare i vettori su un altro store mantenendo la stessa architettura — perché dipendi dalle interfacce, non dai provider.

Il tutorial in 8 mosse
  1. 01
    Ollama + modellillama3.1 + nomic-embed-text
  2. 02
    Redis 8 in DockerQuery Engine integrato
  3. 03
    Progetto + NuGetExtensions.AI + OllamaSharp + NRedisStack
  4. 04
    appsettings + Optionssoglia e TTL tipizzati
  5. 05
    SemanticCacheStoreindice HNSW + KNN
  6. 06
    SemanticCachingChatClientDelegatingChatClient
  7. 07
    AddSemanticCacheesatta → semantica → modello
  8. 08
    Endpoint /chatrisposta, fonte, millisecondi

Da qui: metriche di hit, soglia adattiva, warming della cache.

🎁 Bonus · Redis anche come output cache: l'API /users su MongoDB

Redis è già lì: perché usarlo solo per l'LLM? Nel progetto aggiungo un secondo caso d'uso: l'output caching delle API. Un endpoint GET /users legge una collezione MongoDB e la sua intera risposta HTTP viene cachata su Redis per 60 secondi: le richieste successive non toccano né il database né il codice dell'endpoint.

L'accesso ai dati segue l'architettura che uso nei progetti reali: un solo IRepository<T> generico su un solo database (pocsemantic-caching-llm) — niente classi repository per collezione: il tipo si chiude al volo all'iniezione (IRepository<User>) e la collezione si risolve da un attributo [BsonCollection].

  • Un repository, un database: l'open generic IRepository<>MongoRepository<> registrato una volta sola.
  • Paginazione con il driver nativo: FindAsync esegue un conteggio e una query separata ordinata, con skip e limit.
  • UpdateFieldsAsync: update parziali, mai il replace dell'intero documento.
MongoRepository.cs
using System.Linq.Expressions;
using System.Reflection;
using MongoDB.Bson;
using MongoDB.Driver;
using SemanticCacheDemo.Domain;

namespace SemanticCacheDemo.Persistence;

// Generic Mongo repository: one instance per entity type, all against the single
// "pocsemantic-caching-llm" database injected by DI. The collection name comes
// from [BsonCollection] (or the lowercased pluralised type name as fallback).
public class MongoRepository<T> : IRepository<T> where T : class
{
    // Cached "does T expose a nullable DateTime UpdatedAt?" decision per closed
    // generic T, so UpdateFieldsAsync pays a single boolean branch, not reflection.
    private static readonly bool HasUpdatedAtProperty =
        typeof(T).GetProperty("UpdatedAt")?.PropertyType == typeof(DateTime?);

    private readonly IMongoCollection<T> _collection;

    public MongoRepository(IMongoDatabase database)
    {
        var attr = typeof(T).GetCustomAttribute<BsonCollectionAttribute>();
        var collectionName = attr?.CollectionName ?? typeof(T).Name.ToLowerInvariant() + "s";
        _collection = database.GetCollection<T>(collectionName);
    }

    /// <summary>Exposes the collection as an IQueryable for ad-hoc LINQ queries.</summary>
    public IQueryable<T> GetQueryable() => _collection.AsQueryable();

    public IQueryable<T> FindAllAsQueryable(Expression<Func<T, bool>> predicate) =>
        _collection.AsQueryable().Where(predicate);

    public async Task<T?> GetByIdAsync(string id, CancellationToken ct = default) =>
        await _collection.Find(BuildIdFilter(id)).FirstOrDefaultAsync(ct);

    /// <summary>Server-side paging: total count + one sorted, skipped, limited page.</summary>
    public async Task<(IEnumerable<T> Items, long TotalCount)> FindAsync<TKey>(
        Expression<Func<T, bool>> predicate,
        Expression<Func<T, TKey>> orderBy,
        bool ascending = true,
        int skip = 0,
        int take = 50,
        CancellationToken ct = default)
    {
        var totalCount = await _collection.CountDocumentsAsync(predicate, cancellationToken: ct);

        var fieldDef = new ExpressionFieldDefinition<T, TKey>(orderBy);
        var sort = ascending
            ? Builders<T>.Sort.Ascending(fieldDef)
            : Builders<T>.Sort.Descending(fieldDef);

        var items = await _collection.Find(predicate)
            .Sort(sort)
            .Skip(skip)
            .Limit(take)
            .ToListAsync(ct);

        return (items, totalCount);
    }

    public async Task<T> AddAsync(T entity, CancellationToken ct = default)
    {
        await _collection.InsertOneAsync(entity, cancellationToken: ct);
        return entity;
    }

    public async Task AddManyAsync(IEnumerable<T> entities, CancellationToken ct = default) =>
        await _collection.InsertManyAsync(entities, cancellationToken: ct);

    /// <summary>Partial update by id; stamps UpdatedAt when the entity declares it.</summary>
    public async Task<bool> UpdateFieldsAsync(string id, UpdateDefinition<T> update, CancellationToken ct = default)
    {
        update = AddUpdatedAt(update);
        var result = await _collection.UpdateOneAsync(BuildIdFilter(id), update, cancellationToken: ct);
        return result.MatchedCount > 0;
    }

    public async Task<bool> ExistsAsync(Expression<Func<T, bool>> predicate, CancellationToken ct = default) =>
        await _collection.CountDocumentsAsync(predicate, new CountOptions { Limit = 1 }, ct) > 0;

    public async Task<long> CountAsync(Expression<Func<T, bool>>? predicate = null, CancellationToken ct = default) =>
        predicate is null
            ? await _collection.EstimatedDocumentCountAsync(cancellationToken: ct)
            : await _collection.CountDocumentsAsync(predicate, cancellationToken: ct);

    public async Task DeleteAsync(string id, CancellationToken ct = default) =>
        await _collection.DeleteOneAsync(BuildIdFilter(id), ct);

    // Stamps "UpdatedAt = DateTime.UtcNow" when the entity declares a DateTime? UpdatedAt.
    private static UpdateDefinition<T> AddUpdatedAt(UpdateDefinition<T> update) =>
        !HasUpdatedAtProperty
            ? update
            : Builders<T>.Update.Combine(update, Builders<T>.Update.Set("UpdatedAt", DateTime.UtcNow));

    private static FilterDefinition<T> BuildIdFilter(string id)
    {
        if (!ObjectId.TryParse(id, out var oid))
            throw new ArgumentException($"Invalid ObjectId: {id}", nameof(id));

        return Builders<T>.Filter.Eq("_id", oid);
    }
}
Un solo repository generico: collezione da [BsonCollection], paginazione server-side in due operazioni, update parziali con UpdatedAt automatico.

🧩 Bonus · Il service layer: query paginata in LINQ

L'endpoint non parla mai col repository direttamente: in mezzo c'è un service (IUserService/UserService) che compone il filtro come espressione LINQ tipizzataContains su cognome, nome ed email quando c'è una ricerca — e la passa al repository insieme a ordinamento e paginazione. In uscita un DTO tipizzato PagedResult<UserResponse> con conteggio totale e pagine.

Tenere la materializzazione dietro IRepository ha un doppio vantaggio: il repository esegue il conteggio e il recupero della pagina come due operazioni server-side, e il service resta unit-testabile con un repository sostituito (lo vediamo nella sezione test).

UserService.cs
using System.Linq.Expressions;
using SemanticCacheDemo.Domain;
using SemanticCacheDemo.Mappings;
using SemanticCacheDemo.Models;
using SemanticCacheDemo.Persistence;

namespace SemanticCacheDemo.Services;

// The service injects the SAME generic IRepository<T> everyone else uses, closed
// on User at injection time — no per-collection repository class. It composes the
// filter as a typed LINQ expression and hands it to the repository, which pages
// server-side; keeping materialization behind IRepository is what makes this
// class unit-testable with a substituted repository.
public sealed class UserService(IRepository<User> repository) : IUserService
{
    // Returns one page of users, optionally narrowed by a free-text search, already
    // projected onto the public wire contract.
    public async Task<PagedResult<UserResponse>> GetPagedAsync(GetUsersRequest request, CancellationToken ct = default)
    {
        // 1) Compose the filter as a typed expression: a blank search matches everything,
        //    otherwise the term is looked for across last name, first name and email.
        var search = request.Search;
        Expression<Func<User, bool>> predicate = string.IsNullOrWhiteSpace(search)
            ? _ => true
            : u => u.LastName.Contains(search) ||
                   u.FirstName.Contains(search) ||
                   u.Email.Contains(search);

        // 2) Page server-side — the repository translates skip/take and returns the
        //    filtered total alongside the matching slice.
        var (users, totalCount) = await repository.FindAsync(
            predicate,
            u => u.LastName,
            ascending: true,
            skip: (request.Page - 1) * request.PageSize,
            take: request.PageSize,
            ct: ct);

        // 3) Project entities -> DTOs and wrap them in the paged envelope.
        return users.ToPagedResult(request, totalCount);
    }
}
Espressione LINQ composta nel service, paginazione nel repository, DTO tipizzato in uscita.

🛡️ Bonus · FluentValidation sui parametri di paginazione

I parametri di GET /users arrivano dalla query string e vanno validati prima di toccare il database: pagina ≥ 1, pageSize tra 1 e 100, ricerca non oltre i 100 caratteri. Uso FluentValidation: un validator per il record di richiesta, registrato in blocco con AddValidatorsFromAssemblyContaining.

Nell'endpoint basta iniettare IValidator<GetUsersRequest>: se la validazione fallisce, rispondo 400 con un ValidationProblem senza eseguire alcuna query.

GetUsersRequestValidator.cs
using FluentValidation;
using SemanticCacheDemo.Models;

namespace SemanticCacheDemo.Validators;

// Validates GET /users input: out-of-range paging surfaces as 400 with a message
// instead of running an absurd query (page=0, pageSize=5000, ...).
public sealed class GetUsersRequestValidator : AbstractValidator<GetUsersRequest>
{
    public GetUsersRequestValidator()
    {
        RuleFor(x => x.Page)
            .GreaterThanOrEqualTo(1)
            .WithMessage("Page must be >= 1");

        RuleFor(x => x.PageSize)
            .InclusiveBetween(1, 100)
            .WithMessage("PageSize must be between 1 and 100");

        RuleFor(x => x.Search)
            .MaximumLength(100)
            .When(x => x.Search is not null)
            .WithMessage("Search cannot exceed 100 characters");
    }
}

FluentValidation · Docs

Regole esplicite e messaggi chiari: l'input sbagliato muore prima della query.

⚡ Bonus · L'endpoint /users con output caching su Redis

Ecco l'endpoint completo: binding tipizzato con [AsParameters], validazione, service e — la parte interessante — .CacheOutput(): la risposta viene salvata su Redis (pacchetto Microsoft.AspNetCore.OutputCaching.StackExchangeRedis), varia per i parametri di paging e porta il tag users.

Il tag è la chiave dell'invalidazione: tutte le pagine cachate di /users appartengono allo stesso tag e si possono buttare via in un colpo solo.

UserEndpoints.cs
using FluentValidation;
using SemanticCacheDemo.Models;
using SemanticCacheDemo.Services;

namespace SemanticCacheDemo.Endpoints;

public static class UserEndpoints
{
    // GET /users?page=&pageSize=&search= — validated with FluentValidation, served
    // by the service layer, and OUTPUT-CACHED on Redis for 60 seconds. The cache
    // varies by the paging params and carries the "users" tag so it can be evicted.
    public static void MapUserEndpoints(this IEndpointRouteBuilder app) =>
        app.MapGet("/users", async (
            [AsParameters] GetUsersRequest request,
            IValidator<GetUsersRequest> validator,
            IUserService users,
            CancellationToken ct) =>
        {
            var validation = await validator.ValidateAsync(request, ct);
            if (!validation.IsValid)
                return Results.ValidationProblem(validation.ToDictionary());

            return Results.Ok(await users.GetPagedAsync(request, ct));
        })
        .CacheOutput(policy => policy
            .Expire(TimeSpan.FromSeconds(60))
            .SetVaryByQuery("page", "pageSize", "search")
            .Tag("users"));
}

Output caching · Microsoft Learn

Validazione, service e CacheOutput con tag: 60 secondi su Redis, poi query fresca.

🧹 Bonus · Svuotare la cache a comando (e il file .http per provare tutto)

A volte la cache va svuotata subito — un import massivo, un dato corretto a mano. DELETE /cache/output chiama IOutputCacheStore.EvictByTagAsync("users") ed elimina tutte le risposte cachate con quel tag: la prossima GET rifà la query su MongoDB.

Nel progetto c'è anche un file .http (VS Code REST Client / Rider) con tutte le chiamate pronte: i tre giri della cache semantica, un prompt chat non valido, la /users paginata, il paging non valido e il flush della cache.

SemanticCacheDemo.http
@host = http://localhost:5050

### ---------- LLM: two-layer semantic cache ----------

### 1) First call — real model run (source: "model", slow)
POST {{host}}/chat
Content-Type: application/json

{ "prompt": "What is Redis in one sentence?" }

### 2) Identical prompt — exact-match layer (elapsedMs collapses)
POST {{host}}/chat
Content-Type: application/json

{ "prompt": "What is Redis in one sentence?" }

### 3) Reworded prompt — semantic layer (source: "semantic-cache")
POST {{host}}/chat
Content-Type: application/json

{ "prompt": "In a single sentence, describe what Redis is." }

### 4) Missing prompt — rejected as 400 ValidationProblem before Ollama
POST {{host}}/chat
Content-Type: application/json

{}

### ---------- Users: Mongo + Redis OUTPUT cache ----------

### Paged users — first page (MISS on first call, then cached 60s on Redis)
GET {{host}}/users?page=1&pageSize=10

### Paged users — second page
GET {{host}}/users?page=2&pageSize=10

### Paged users — search filter (case-sensitive contains on name/email)
GET {{host}}/users?page=1&pageSize=5&search=an

### Paged users — INVALID paging -> 400 ValidationProblem (FluentValidation)
GET {{host}}/users?page=0&pageSize=500

### ---------- Cache management ----------

### Force-flush the Redis OUTPUT cache (tag "users") -> next GET /users is fresh
DELETE {{host}}/cache/output
Tutte le chiamate pronte: chat, validazione prompt, utenti paginati, validazione paging e flush.

🎲 Bonus · Bogus: dati finti realistici (e il job che crea gli indici)

Per avere una collezione su cui paginare servono dati. Li genero con Bogus (github.com/bchavez/Bogus), la libreria .NET per i dati finti realistici: dichiari una Faker<T> con una regola per campo — nomi, email, città, date — e chiedi quanti oggetti vuoi. UseSeed rende ripetibile la sequenza pseudo-casuale di Bogus e f.UniqueIndex tiene uniche le email, ma il dataset completo non è interamente riproducibile perché le regole sulle date dipendono da DateTime.UtcNow.

Un initializer di startup svolge due compiti idempotenti: assicurarsi che gli indici della collezione esistano — email univoca, lastName+firstName per la paginazione, città — e fare il seed di 500 utenti solo se la collezione è vuota. Program.cs lo attende prima di avviare Kestrel, così una risposta vuota non può finire nell'output cache mentre il seed è ancora in corso.

UserCollectionInitializer.cs
using Bogus;
using MongoDB.Driver;
using SemanticCacheDemo.Domain;
using SemanticCacheDemo.Persistence;

namespace SemanticCacheDemo.HostedServices;

// Startup initializer with two duties, both idempotent:
// 1) make sure the indexes on the users collection exist (unique email,
//    lastName+firstName for the paged sort, city);
// 2) seed 500 realistic fake users with Bogus when the collection is empty.
// Program awaits this service before Kestrel starts accepting requests, preventing an
// empty /users response from being cached while the seed is still running.
public sealed class UserCollectionInitializer(
    IServiceScopeFactory scopeFactory,
    ILogger<UserCollectionInitializer> logger)
{
    public async Task InitializeAsync(CancellationToken cancellationToken = default)
    {
        using var scope = scopeFactory.CreateScope();

        // Index creation has no LINQ equivalent — this is the driver's native API.
        var database = scope.ServiceProvider.GetRequiredService<IMongoDatabase>();
        var collection = database.GetCollection<User>("users");

        var indexes = new List<CreateIndexModel<User>>
        {
            new(Builders<User>.IndexKeys.Ascending(u => u.Email),
                new CreateIndexOptions { Unique = true, Name = "ux_email" }),
            new(Builders<User>.IndexKeys.Ascending(u => u.LastName).Ascending(u => u.FirstName),
                new CreateIndexOptions { Name = "ix_lastname_firstname" }),
            new(Builders<User>.IndexKeys.Ascending(u => u.City),
                new CreateIndexOptions { Name = "ix_city" }),
        };
        await collection.Indexes.CreateManyAsync(indexes, cancellationToken);
        logger.LogInformation("users indexes ensured ({Count})", indexes.Count);

        var repository = scope.ServiceProvider.GetRequiredService<IRepository<User>>();
        if (await repository.CountAsync(ct: cancellationToken) > 0)
            return;

        // Bogus: UseSeed repeats the pseudo-random sequence, while date rules
        // anchored to DateTime.UtcNow still vary between runs;
        // f.UniqueIndex keeps emails unique so the ux_email index never trips.
        var faker = new Faker<User>()
            .UseSeed(1337)
            .RuleFor(u => u.FirstName, f => f.Name.FirstName())
            .RuleFor(u => u.LastName, f => f.Name.LastName())
            .RuleFor(u => u.Email, (f, u) =>
                $"{u.FirstName}.{u.LastName}.{f.UniqueIndex}@example.com".ToLowerInvariant())
            .RuleFor(u => u.City, f => f.Address.City())
            .RuleFor(u => u.Country, f => f.Address.Country())
            .RuleFor(u => u.BirthDate, f => f.Date.Past(60, DateTime.UtcNow.AddYears(-18)))
            .RuleFor(u => u.CreatedAt, f => f.Date.Recent(90));

        await repository.AddManyAsync(faker.Generate(500), cancellationToken);
        logger.LogInformation("users collection seeded with 500 fake users (Bogus)");
    }
}

Bogus · GitHub

Inizializzazione attesa allo startup: indici idempotenti e 500 utenti Bogus prima di servire richieste.

🧪 Bonus · Unit test con xUnit: la cache si testa senza Redis

Nel repo c'è un progetto SemanticCacheDemo.Tests (xUnit + NSubstitute + FluentAssertions) con 19 metodi di test che generano 28 casi rilevati su quattro classi. Il trucco è nei seam: il middleware dipende da ISemanticCacheStore (non dal client Redis) e il service da IRepository<User> — quindi questi unit test girano senza Redis, senza MongoDB e senza Ollama.

Cosa coprono: i validator di chat e paging (prompt mancante, limiti di pagina/ricerca e valori al confine), il service (mappatura DTO, skip/take, pagine totali e predicato di ricerca) e il middleware semantico (hit, miss, prompt vuoto, conversazione con contesto e passthrough con ChatOptions). Store Redis e repository restano i confini di integrazione, coperti dai flussi reali del file .http.

  • xUnit 2.9 + NSubstitute per i sostituti + FluentAssertions per le asserzioni.
  • Un test per comportamento: nome `Metodo_Condizione_Esito`, Theory/InlineData per i confini.
  • `dotnet test` dalla radice del repo: gira tutta la suite in pochi secondi.
SemanticCachingChatClientTests.cs
using FluentAssertions;
using Microsoft.Extensions.AI;
using NSubstitute;
using SemanticCacheDemo.Caching;
using Xunit;

namespace SemanticCacheDemo.Tests;

// The semantic middleware in isolation: inner chat client, embedding generator and
// store are all substituted. These tests pin the contract — hit answers from cache
// without touching the model, miss calls the model and persists, and a message
// list with no user prompt passes straight through.
public class SemanticCachingChatClientTests
{
    private static readonly float[] Vector = [0.1f, 0.2f, 0.3f];

    private readonly IChatClient _inner = Substitute.For<IChatClient>();
    private readonly IEmbeddingGenerator<string, Embedding<float>> _embedder =
        Substitute.For<IEmbeddingGenerator<string, Embedding<float>>>();
    private readonly ISemanticCacheStore _store = Substitute.For<ISemanticCacheStore>();
    private readonly SemanticCachingChatClient _client;

    public SemanticCachingChatClientTests()
    {
        _embedder.GenerateAsync(
                Arg.Any<IEnumerable<string>>(),
                Arg.Any<EmbeddingGenerationOptions?>(),
                Arg.Any<CancellationToken>())
            .Returns(new GeneratedEmbeddings<Embedding<float>>([new Embedding<float>(Vector)]));

        _inner.GetResponseAsync(
                Arg.Any<IEnumerable<ChatMessage>>(),
                Arg.Any<ChatOptions?>(),
                Arg.Any<CancellationToken>())
            .Returns(new ChatResponse(new ChatMessage(ChatRole.Assistant, "fresh answer")));

        _client = new SemanticCachingChatClient(_inner, _embedder, _store);
    }

    private static List<ChatMessage> Prompt(string text) => [new(ChatRole.User, text)];

    [Fact]
    public async Task GetResponseAsync_SemanticHit_AnswersFromCacheWithoutCallingTheModel()
    {
        _store.FindAsync(Arg.Any<ReadOnlyMemory<float>>()).Returns("cached answer");

        var response = await _client.GetResponseAsync(Prompt("What is Redis?"));

        response.Text.Should().Be("cached answer");
        response.AdditionalProperties!["cacheHit"].Should().Be("semantic");
        await _inner.DidNotReceiveWithAnyArgs().GetResponseAsync(default!, default, default);
        await _store.DidNotReceiveWithAnyArgs().SaveAsync(default, default!, default!);
    }

    [Fact]
    public async Task GetResponseAsync_Miss_CallsTheModelAndPersistsTheFreshAnswer()
    {
        _store.FindAsync(Arg.Any<ReadOnlyMemory<float>>()).Returns((string?)null);

        var response = await _client.GetResponseAsync(Prompt("What is Redis?"));

        response.Text.Should().Be("fresh answer");
        await _inner.ReceivedWithAnyArgs(1).GetResponseAsync(default!, default, default);
        await _store.Received(1).SaveAsync(Arg.Any<ReadOnlyMemory<float>>(), "What is Redis?", "fresh answer");
    }

    [Fact]
    public async Task GetResponseAsync_NoUserMessage_PassesThroughWithoutEmbedding()
    {
        var systemOnly = new List<ChatMessage> { new(ChatRole.System, "You are terse.") };

        var response = await _client.GetResponseAsync(systemOnly);

        response.Text.Should().Be("fresh answer");
        await _embedder.DidNotReceiveWithAnyArgs().GenerateAsync(default!, default, default);
        await _store.DidNotReceiveWithAnyArgs().FindAsync(default);
    }

    [Fact]
    public async Task GetResponseAsync_BlankUserMessage_PassesThroughWithoutEmbedding()
    {
        var response = await _client.GetResponseAsync(Prompt("   "));

        response.Text.Should().Be("fresh answer");
        await _embedder.DidNotReceiveWithAnyArgs().GenerateAsync(default!, default, default);
        await _store.DidNotReceiveWithAnyArgs().FindAsync(default);
    }

    [Fact]
    public async Task GetResponseAsync_ContextualConversation_PassesThroughWithoutSemanticCaching()
    {
        var messages = new List<ChatMessage>
        {
            new(ChatRole.System, "Answer as a security reviewer."),
            new(ChatRole.User, "What is Redis?"),
        };

        var response = await _client.GetResponseAsync(messages);

        response.Text.Should().Be("fresh answer");
        await _inner.Received(1).GetResponseAsync(messages, null, Arg.Any<CancellationToken>());
        await _embedder.DidNotReceiveWithAnyArgs().GenerateAsync(default!, default, default);
        await _store.DidNotReceiveWithAnyArgs().FindAsync(default);
    }

    [Fact]
    public async Task GetResponseAsync_ChatOptionsPresent_PassesThroughWithoutSemanticCaching()
    {
        var options = new ChatOptions();

        var response = await _client.GetResponseAsync(Prompt("What is Redis?"), options);

        response.Text.Should().Be("fresh answer");
        await _inner.ReceivedWithAnyArgs(1).GetResponseAsync(default!, options, default);
        await _embedder.DidNotReceiveWithAnyArgs().GenerateAsync(default!, default, default);
        await _store.DidNotReceiveWithAnyArgs().FindAsync(default);
    }
}

xUnit · Docs

Hit, miss e passthrough sicuro rispetto al contesto verificati con inner client, embedder e store sostituiti.

🔮 Prossimo articolo · Hybrid search: dimmi tu il database

La cache semantica di questo articolo usa la sola ricerca vettoriale. Il prossimo passo naturale è la hybrid search: vettori più keyword (BM25), fusi in un'unica classifica — la marcia in più quando nei prompt ci sono sigle, codici e nomi propri.

Nel prossimo articolo (tra pochi giorni) lo mostro passo passo con un database che la supporta. Le opzioni non sono equivalenti: Qdrant supporta query ibride con fusione RRF; ChromaDB documenta la hybrid search con vettori sparsi BM25/SPLADE nella Search API Cloud; MongoDB Community richiede self-managed Search su 8.2+ e un processo mongot separato. Se ne preferisci uno in particolare, fammelo sapere in un commento al post LinkedIn di questo articolo: scelgo il più richiesto.

📦 Il progetto completo su GitHub

Il progetto companion completo di questo tutorial — store Redis, middleware semantico sicuro rispetto al contesto, bonus MongoDB, repository generico, output caching, file .http e suite di unit test xUnit — è pubblico su GitHub. Il README riporta lo stesso flusso Docker Compose, `dotnet test` e `dotnet run` mostrato qui.

Terminale · clona ed esegui
git clone https://github.com/fscamuzzi/semantic-caching-llm-dotnet-redis.git
cd semantic-caching-llm-dotnet-redis
docker compose up -d          # Redis 8 + Ollama + pull dei modelli
docker compose logs -f ollama-init  # attendi exit code 0
cd SemanticCacheDemo && dotnet run --urls http://localhost:5050

fscamuzzi/semantic-caching-llm-dotnet-redis · GitHub

Repository companion pubblico con codice completo, test e dipendenze Docker Compose.

Domande frequenti su semantic caching per LLM

Che cos'è il semantic caching per un LLM?

È una cache che ragiona per significato: se una domanda è semanticamente vicina a una già vista — anche se riformulata — restituisce la risposta salvata invece di rieseguire il modello. Riduce latenza e costo sulle domande ricorrenti.

Che differenza c'è tra cache esatta e cache semantica?

La cache esatta (UseDistributedCache) risponde solo a prompt identici, con un hash dei messaggi e costo quasi nullo. Quella semantica genera un embedding e cerca il vettore più vicino, così cattura anche le riformulazioni. Le uso insieme, in cascata.

Basta UseDistributedCache da solo?

Cattura solo i prompt identici. Nella realtà gli utenti riformulano: «Cos'è X?» e «Mi spieghi X?» non hanno lo stesso hash. Per questo aggiungo il livello semantico, che le riconosce come equivalenti.

Serve Redis Stack o basta Redis?

Basta Redis 8: dalla versione 8 il Query Engine (ricerca vettoriale e full-text) è integrato nell'immagine core, quindi niente più modulo Redis Stack separato. Uso NRedisStack come client .NET.

Come evito risposte stale o hit troppo aggressivi?

Con due leve: una soglia di distanza non troppo permissiva (più bassa = più severa) e un TTL che fa scadere le voci semantiche. In questa demo il livello a match esatto non ha una scadenza configurata. Ed evito di cachare risposte personalizzate o legate al tempo. Meglio un miss in più che una risposta sbagliata.

Posso usarlo con OpenAI o Azure invece di Ollama?

Sì. La cache dipende solo da IChatClient e IEmbeddingGenerator: cambio il client interno (OpenAI, Azure OpenAI, Azure AI Inference) e la pipeline resta identica. È il vantaggio delle astrazioni di Microsoft.Extensions.AI.

Che differenza c'è tra output caching e cache semantica?

L'output caching salva l'intera risposta HTTP di un endpoint (qui GET /users) per una durata fissa: chiave esatta, zero embedding. La cache semantica lavora sul significato dei prompt dell'LLM. Nel progetto convivono sullo stesso Redis: una per le API, l'altra per il modello.

Parliamone

Se questo tema ti riguarda, scrivimi: confrontarsi su codice e AI è sempre tempo speso bene.

Altri articoli del blog

tips-claude-claude-md-luglio-2026.md24 luglio 2026claude-code-pretooluse-hook-proteggere-env-segreti-2026.md24 luglio 2026claude-cowork-record-a-skill-2026.md24 luglio 2026