Hybrid search in .NET con Qdrant, BM25 e Ollama
Nell'articolo sul semantic caching avevo chiesto quale database usare per questo tutorial: la risposta è Qdrant. La ricerca solo vettoriale ha un punto cieco noto: le sigle, i codici di errore, i nomi propri. Un embedding capisce benissimo che «come cambio la password» e «reset delle credenziali» sono la stessa domanda, ma davanti a «NB-1042» annaspa: per il modello è una stringa opaca, senza semantica. La ricerca keyword (BM25) ha il problema opposto: inchioda il codice esatto ma non vede i sinonimi. La hybrid search le usa entrambe e fonde le due classifiche in una sola con la Reciprocal Rank Fusion. In questo tutorial la costruisco in ASP.NET Core (.NET 10): una collection Qdrant con due vettori nominati — denso e sparso — embedding locali con Ollama via Microsoft.Extensions.AI, BM25 calcolato direttamente dal database e un endpoint che confronta le tre modalità fianco a fianco. Poi porto il retrieval fino in fondo: un endpoint /ask che fa vera RAG — i documenti recuperati finiscono nel prompt di llama3.1 e la risposta del modello torna al client con le fonti — una semantic cache delle risposte su Qdrant, Redis per l'output caching delle API e MongoDB con utenti finti e paginazione. Tutto in locale: niente chiavi API, niente costi a token.
🔍 Che cos'è la hybrid search
La hybrid search esegue la stessa query su due rappresentazioni dello stesso documento — un vettore denso (l'embedding, che cattura il significato) e un vettore sparso (le keyword pesate, che catturano i termini esatti) — e poi fonde le due classifiche in una sola. Non è una media dei punteggi: è una fusione per posizione, che non richiede di normalizzare scale diverse.
L'idea in pseudocodice è tutta qui: due ricerche, una fusione. La parte interessante è che con Qdrant le due ricerche e la fusione avvengono in un'unica chiamata al database, non in tre round-trip orchestrati a mano.
# a ogni ricerca
dense_hits = vector_search(embed(q), top=20) # semantica
sparse_hits = bm25_search(q, top=20) # keyword esatte
# fusione per POSIZIONE, non per punteggio
for hit in dense_hits + sparse_hits:
score[hit.id] += 1 / (k + rank(hit)) # RRF
return top(score, limit) # una sola classificaCodice completo · fscamuzzi/hybrid-search-dotnet-qdrant-ollama ↗
🧮 Dense vs sparse: la differenza in parole semplici
Prima di scendere nel codice mi fermo un attimo sulle due ricerche che la hybrid search mette a fattor comune, perché è la chiave per capire ogni scelta successiva. Dense (semantico): capisce il significato. Il testo passa da un modello AI — qui Ollama — che lo trasforma in un vettore di numeri «pieno» (768 dimensioni, tutte valorizzate). Due frasi con parole diverse ma significato simile risultano vicine: «il cane abbaia» ≈ «il mio labrador fa rumore». Perfetto quando chi cerca non usa le parole esatte del documento.
Sparse (BM25): cerca le parole esatte. È l'evoluzione della ricerca keyword classica: il vettore è enorme ma quasi tutto zero, con valori solo in corrispondenza delle parole presenti nel testo, pesate per rarità e frequenza. Trova match solo se le parole coincidono — cerchi `ERR_CONN_5012` e trovi solo i documenti che contengono esattamente quel codice. Perfetto per codici errore, nomi propri, sigle, termini tecnici: cose che un modello semantico spesso annacqua
Dense
- Come nasce: embedding da Ollama
- Trova: significato simile
- Forte su: domande in linguaggio naturale
- Debole su: termini rari/tecnici precisi
Sparse (BM25)
- Come nasce: calcolato da Qdrant server-side
- Trova: parole identiche
- Forte su: codici, sigle, nomi esatti
- Debole su: sinonimi e parafrasi
I due named vectors nella stessa collection servono a questo: lancio entrambe le ricerche e fondo i risultati, tipicamente con RRF — il pattern giusto per un retrieval che deve capire sia il linguaggio naturale sia i codici esatti.
🎯 Quando serve davvero
Se i tuoi utenti cercano solo frasi discorsive, la ricerca densa basta quasi sempre. La hybrid search diventa la marcia in più quando nel dominio ci sono termini esatti che contano: codici di errore, SKU, nomi di API, sigle normative, identificativi. Sono proprio i token che un embedding generalista rappresenta peggio — e che BM25, grazie all'IDF, premia di più perché rari nel corpus.
L'altro caso tipico è la RAG: un retrieval ibrido porta al modello chunk più pertinenti quando la domanda mescola linguaggio naturale e termini tecnici («perché NB-429 continua a fallire?»). Stessi mattoni, stesso indice, risposta finale migliore.
- Vince il denso: parafrasi, sinonimi, domande discorsive senza parole in comune con il documento.
- Vince BM25: codici, sigle, nomi propri, query di una-due parole molto specifiche.
- Vince l'ibrido: query miste («NB-1042 signature check fails») e corpus con gergo di dominio.
Dense (embedding)
- Capisce parafrasi e sinonimi
- Nessun bisogno di parole in comune
- Debole su codici e sigle rare
- Costo: un embedding per query
Keyword (BM25)
- Esatta su termini rari (IDF)
- Spiegabile: match visibili
- Cieca sui sinonimi
- Costo: nessun embedding
Punti di forza complementari: per questo la fusione delle due classifiche batte ciascuna delle due da sola.
⚖️ RRF: come si fondono due classifiche
Il problema della fusione è che i punteggi delle due ricerche vivono su scale diverse: la similarità coseno è limitata, BM25 è illimitato. Sommarli o mediarli senza normalizzare è una lotteria. La Reciprocal Rank Fusion (RRF) taglia il nodo: ignora i punteggi e guarda solo le posizioni. Ogni documento riceve la somma di 1/(k + rank) per ogni classifica in cui compare; in Qdrant k vale 2 e il rank parte da zero.
Il risultato è intuitivo: un documento ai primi posti in entrambe le classifiche scavalca chi eccelle in una sola. Qdrant offre anche una fusione alternativa, la DBSF (Distribution-Based Score Fusion), che normalizza i punteggi con media e deviazione standard e poi li somma: ne parlo nella sezione sul tuning.
Solo posizioni, mai punteggi grezzi: niente normalizzazione tra coseno e BM25.
🏗️ L'architettura della soluzione
Costruisco una Minimal API in .NET 10 il cui cuore è IHybridSearchStore, il seam dietro cui vive Qdrant. L'endpoint GET /search accetta tre modalità — dense, keyword, hybrid — così le differenze si vedono sulla stessa base di conoscenza: 14 articoli di una KB fittizia, con tanto di codici di errore tipo NB-1042. Attorno al retrieval ci sono altri tre pezzi: POST /ask (la RAG vera e propria, con semantic cache su Qdrant), GET /users (MongoDB con paginazione, output cache su Redis) e DELETE /cache/output.
La collection Qdrant ha due vettori nominati per ogni punto: dense (embedding di Ollama, 768 dimensioni, coseno) e bm25 (vettore sparso). La parte che semplifica tutto: da Qdrant 1.15.2 il vettore BM25 lo calcola il database lato server — passo il testo grezzo con il modello qdrant/bm25 e Qdrant fa tokenizzazione, stemming e pesi. Niente matematica BM25 nel codice C#. Codice completo su GitHub: github.com/fscamuzzi/hybrid-search-dotnet-qdrant-ollama.
- Modelli: Ollama con nomic-embed-text (768 dimensioni) via IEmbeddingGenerator — per la sola hybrid search non serve un modello di chat; llama3.1 entra in gioco per le risposte RAG di /ask.
- Database: Qdrant v1.18 (Docker) per ricerca e semantic cache, MongoDB 8 per gli utenti, Redis 8 per exact-match e output cache.
- Pacchetti: Microsoft.Extensions.AI, OllamaSharp, Qdrant.Client, MongoDB.Driver, Bogus, StackExchangeRedis (output cache), FluentValidation.
Le due ricerche e la fusione avvengono dentro Qdrant, in un solo round-trip gRPC.
🐳 Passo 1 · Qdrant e Ollama con Docker Compose
Le dipendenze esterne sono quattro: Qdrant, Ollama, Redis e MongoDB. Le metto in Docker Compose con healthcheck e volumi; un servizio one-shot scarica i due modelli — nomic-embed-text per i vettori e llama3.1 per le risposte RAG — appena Ollama è pronto. L'app gira sull'host con dotnet run e parla con i container su localhost.
Un dettaglio da non sbagliare: Qdrant espone due porte. La 6333 è la REST (e la dashboard su /dashboard), la 6334 è la gRPC — ed è quella che usa il client .NET. Prima di avviare l'API eseguo docker compose logs -f ollama-init e aspetto che il download del modello termini con codice 0.
name: hybrid-search-dotnet-qdrant-ollama
# 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 Qdrant, Ollama, Redis 8 and MongoDB, and pulls the two models into
# the Ollama volume. MongoDB uses host port 27018 to avoid the 27017 conflict.
services:
qdrant:
image: qdrant/qdrant:v1.18.1
container_name: hybrid-search-qdrant
ports:
- "6333:6333" # REST + web dashboard (http://localhost:6333/dashboard)
- "6334:6334" # gRPC — the .NET client (Qdrant.Client) uses this one
volumes:
- qdrant-data:/qdrant/storage
healthcheck:
test: ["CMD-SHELL", "bash -c ':> /dev/tcp/127.0.0.1/6333' || exit 1"]
interval: 10s
timeout: 5s
retries: 15
restart: unless-stopped
ollama:
image: ollama/ollama:latest
container_name: hybrid-search-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, exits.
# nomic-embed-text feeds the vectors; llama3.1 answers the RAG questions.
ollama-init:
image: ollama/ollama:latest
depends_on:
ollama:
condition: service_healthy
environment:
- OLLAMA_HOST=ollama:11434
entrypoint: ["/bin/sh", "-c", "ollama pull nomic-embed-text && ollama pull llama3.1"]
restart: "no"
# Redis backs the OUTPUT cache (/users responses) and the exact-match
# cache of the chat pipeline. The SEMANTIC cache lives in Qdrant.
redis:
image: redis:8
container_name: hybrid-search-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: database created on first write; indexes + Bogus
# seeding happen in the awaited startup initializer.
mongodb:
image: mongo:8
container_name: hybrid-search-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:
qdrant-data:
ollama-data:
redis-data:
mongo-data:Local Quickstart · Qdrant Docs ↗
🧱 Passo 2 · Il progetto e i pacchetti NuGet
Creo una Minimal API e aggiungo i pacchetti: le astrazioni Microsoft.Extensions.AI (per IEmbeddingGenerator e IChatClient), OllamaSharp — il cui OllamaApiClient implementa quelle astrazioni, come per qualsiasi provider cloud —, il client ufficiale Qdrant.Client (gRPC), MongoDB.Driver e Bogus per gli utenti finti, i due pacchetti StackExchangeRedis per distributed e output cache, e FluentValidation per l'input degli endpoint.
È lo stesso stack degli articoli precedenti su semantic caching e function calling: cambia solo il database sotto. Se domani volessi passare da Ollama a OpenAI o Azure, cambierei la registrazione dell'embedding generator e ricreerei la collection con le nuove dimensioni.
$ dotnet new web -n HybridSearchDemo && cd HybridSearchDemo
$ dotnet add package Microsoft.Extensions.AI
$ dotnet add package OllamaSharp
$ dotnet add package Qdrant.Client
$ dotnet add package MongoDB.Driver
$ dotnet add package Bogus
$ dotnet add package Microsoft.Extensions.Caching.StackExchangeRedis
$ dotnet add package Microsoft.AspNetCore.OutputCaching.StackExchangeRedis
$ dotnet add package FluentValidation.DependencyInjectionExtensionsMicrosoft.Extensions.AI · Microsoft Learn ↗
⚙️ Passo 3 · La configurazione in appsettings.json
Tutta la configurazione vive in appsettings.json: endpoint di Ollama, modello e dimensioni dell'embedding, host e porta gRPC di Qdrant, nome della collection, i nomi dei due vettori e il PrefetchLimit — quanti candidati ogni ramo passa alla fusione.
Tenere qui questi valori significa cambiare modello, soglia o host senza toccare il codice.
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"HybridSearch": {
"OllamaEndpoint": "http://localhost:11434",
"EmbeddingModel": "nomic-embed-text",
"ChatModel": "llama3.1",
"EmbeddingDimensions": 768,
"QdrantHost": "localhost",
"QdrantPort": 6334,
"CollectionName": "kb_hybrid",
"DenseVectorName": "dense",
"SparseVectorName": "bm25",
"PrefetchLimit": 20,
"RagTopK": 3,
"CacheCollectionName": "semantic_cache",
"CacheMinScore": 0.85,
"CacheTtl": "1.00:00:00",
"RedisConnection": "localhost:6379"
},
"Mongo": {
"ConnectionString": "mongodb://localhost:27018",
"DatabaseName": "pochybrid-search",
"EnableLogQueries": false
}
}Codice completo · fscamuzzi/hybrid-search-dotnet-qdrant-ollama ↗
🗂️ Passo 4 · Le opzioni tipizzate
Lego la sezione a una classe fortemente tipizzata, HybridSearchOptions, così il resto del codice non usa stringhe magiche. EmbeddingDimensions deve combaciare col modello (768 per nomic-embed-text); se cambio embedder ricreo la collection con le nuove dimensioni.
namespace HybridSearchDemo.Configuration;
// Strongly-typed configuration bound from the "HybridSearch" section of
// appsettings.json: Ollama (embeddings + chat), Qdrant (gRPC), the two named
// vectors, the semantic-cache collection and Redis. Changing model, host or
// collection never touches the code.
public sealed class HybridSearchOptions
{
public const string SectionName = "HybridSearch";
// Ollama serves both models locally: nomic-embed-text for the vectors,
// llama3.1 to generate the RAG answers of POST /ask.
public string OllamaEndpoint { get; init; } = "http://localhost:11434";
public string EmbeddingModel { get; init; } = "nomic-embed-text";
public string ChatModel { get; init; } = "llama3.1";
// Must match the embedding model: nomic-embed-text returns 768 dimensions.
public int EmbeddingDimensions { get; init; } = 768;
// Qdrant gRPC endpoint: the .NET client speaks gRPC on 6334, not REST on 6333.
public string QdrantHost { get; init; } = "localhost";
public int QdrantPort { get; init; } = 6334;
public string CollectionName { get; init; } = "kb_hybrid";
// The two named vectors of the search collection: dense (semantic, from
// Ollama) and sparse (BM25, computed server-side by Qdrant).
public string DenseVectorName { get; init; } = "dense";
public string SparseVectorName { get; init; } = "bm25";
// How many candidates each prefetch branch feeds into the RRF fusion stage.
public ulong PrefetchLimit { get; init; } = 20;
// How many fused hits POST /ask packs into the LLM context.
public int RagTopK { get; init; } = 3;
// Semantic cache of the RAG answers — a SECOND Qdrant collection, dense-only.
public string CacheCollectionName { get; init; } = "semantic_cache";
// Min cosine SIMILARITY (1 = identical) accepted as a semantic hit.
public float CacheMinScore { get; init; } = 0.85f;
// Qdrant has no native TTL: entries carry createdAt and lookups filter it.
public TimeSpan CacheTtl { get; init; } = TimeSpan.FromDays(1);
// Redis backs the OUTPUT cache (/users) and the exact-match chat cache.
public string RedisConnection { get; init; } = "localhost:6379";
}Codice completo · fscamuzzi/hybrid-search-dotnet-qdrant-ollama ↗
🧩 Passo 5 · La collection: due vettori nominati
Qui c'è la prima decisione strutturale: una sola collection in cui ogni punto porta due vettori nominati. Il vettore dense è un classico HNSW a 768 dimensioni con metrica coseno; il vettore bm25 è sparso e dichiara il modificatore IDF.
Quel modificatore è obbligatorio con BM25: le rappresentazioni sparse generate dal modello qdrant/bm25 escludono di proposito la componente IDF, perché è Qdrant a mantenerla a livello di collection e ad applicarla al momento della query. Senza modificatore, i termini rari non peserebbero più di quelli comuni.
// Idempotent: one collection, TWO named vectors per point. The sparse side
// enables the IDF modifier — mandatory with BM25: Qdrant maintains the
// corpus statistics and applies IDF at query time.
public async Task EnsureCollectionAsync(CancellationToken ct = default)
{
if (await client.CollectionExistsAsync(_opt.CollectionName, ct))
return;
await client.CreateCollectionAsync(
collectionName: _opt.CollectionName,
vectorsConfig: new VectorParamsMap
{
Map =
{
[_opt.DenseVectorName] = new VectorParams
{
Size = (ulong)_opt.EmbeddingDimensions,
Distance = Distance.Cosine,
},
},
},
sparseVectorsConfig: (_opt.SparseVectorName, new SparseVectorParams { Modifier = Modifier.Idf }),
cancellationToken: ct);
}Indexing e IDF modifier · Qdrant Docs ↗
📥 Passo 6 · L'ingestion: un punto, due rappresentazioni
Il seeding legge 14 articoli KB da un file JSON e per ognuno scrive un punto con entrambi i vettori. Il lato denso lo calcolo io con GenerateVectorAsync di Ollama; il lato sparso lo delego a Qdrant passando un Document: testo grezzo più il modello qdrant/bm25. Tokenizzazione, stemming e pesi BM25 avvengono nel database, non nel mio C#.
Il metodo è idempotente: se la collection contiene già punti, esce subito. Payload title e text viaggiano accanto ai vettori, così la risposta dell'API non deve fare un secondo giro per recuperare i contenuti.
// Seeds the corpus once: skips when the collection already holds points.
public async Task SeedFromFileAsync(string path, CancellationToken ct = default)
{
if (await client.CountAsync(_opt.CollectionName, cancellationToken: ct) > 0)
return;
await using var file = File.OpenRead(path);
var docs = await JsonSerializer.DeserializeAsync<List<KbDocument>>(file, JsonSerializerOptions.Web, ct) ?? [];
// One Ollama call per document: fine for a seed of ~15 KB articles.
var points = new List<PointStruct>();
foreach (var (doc, index) in docs.Select((d, i) => (d, i)))
{
var dense = await embedder.GenerateVectorAsync($"{doc.Title}. {doc.Text}", cancellationToken: ct);
points.Add(new PointStruct
{
Id = (ulong)(index + 1),
Vectors = new Dictionary<string, Vector>
{
// Dense side: the embedding computed locally by Ollama.
[_opt.DenseVectorName] = dense.ToArray(),
// Sparse side: raw text. Qdrant tokenizes, stems and turns it
// into a BM25 sparse vector on the server — no client-side math.
[_opt.SparseVectorName] = new Document { Model = Bm25Model, Text = $"{doc.Title}. {doc.Text}" },
},
Payload = { ["title"] = doc.Title, ["text"] = doc.Text },
});
}
await client.UpsertAsync(_opt.CollectionName, points, cancellationToken: ct);
}Server-side BM25 · Qdrant Docs ↗
🔎 Passo 7 · Le tre ricerche sulla stessa collection
Il metodo pubblico SearchAsync smista sulla strategia richiesta e mappa i punti Qdrant in DTO tipizzati. La ricerca densa embedda la query e chiede i vicini per coseno sul vettore dense; quella keyword passa di nuovo un Document BM25 — è Qdrant a trasformare la query in vettore sparso e ad applicare l'IDF di collection.
Nota il costo asimmetrico: la keyword non paga nessun embedding, la densa sì. È il motivo per cui nel terminale vedrai il BM25 rispondere in pochi millisecondi e le modalità dense/hybrid in qualche decina.
// One entry point, three strategies over the same collection.
public async Task<IReadOnlyList<SearchHit>> SearchAsync(
string query, SearchMode mode, int limit, CancellationToken ct = default)
{
var results = mode switch
{
SearchMode.Dense => await DenseAsync(query, limit, ct),
SearchMode.Keyword => await KeywordAsync(query, limit, ct),
_ => await HybridAsync(query, limit, ct),
};
return [.. results.Select(point => new SearchHit(
point.Id.Num,
point.Score,
point.Payload["title"].StringValue,
point.Payload["text"].StringValue))];
}
// Dense only: embed the query with Ollama, nearest neighbours by cosine.
private async Task<IReadOnlyList<ScoredPoint>> DenseAsync(string query, int limit, CancellationToken ct)
{
var vector = await embedder.GenerateVectorAsync(query, cancellationToken: ct);
return await client.QueryAsync(
_opt.CollectionName,
query: vector.ToArray(),
usingVector: _opt.DenseVectorName,
limit: (ulong)limit,
payloadSelector: true,
cancellationToken: ct);
}
// Keyword only: BM25. The query string becomes a sparse vector inside
// Qdrant, and IDF is applied against the collection-level statistics.
private async Task<IReadOnlyList<ScoredPoint>> KeywordAsync(string query, int limit, CancellationToken ct) =>
await client.QueryAsync(
_opt.CollectionName,
query: new Document { Model = Bm25Model, Text = query },
usingVector: _opt.SparseVectorName,
limit: (ulong)limit,
payloadSelector: true,
cancellationToken: ct);🧬 Passo 8 · La query ibrida: prefetch + RRF
Il cuore dell'articolo sono venti righe. La Query API di Qdrant accetta una lista di prefetch — sotto-ricerche eseguite per prime — e una query principale applicata ai loro risultati. Dichiaro due prefetch, uno per vettore nominato, e come query principale la fusione RRF.
Tutto avviene in un solo round-trip gRPC: niente orchestrazione client-side, niente doppia latenza di rete, niente codice di merge da mantenere. Ogni ramo porta PrefetchLimit candidati (20): abbastanza perché la fusione abbia materiale su cui lavorare, abbastanza pochi da restare veloce.
// Hybrid: both branches run inside Qdrant as prefetches, then Reciprocal
// Rank Fusion merges the two rankings into a single one. One round-trip.
private async Task<IReadOnlyList<ScoredPoint>> HybridAsync(string query, int limit, CancellationToken ct)
{
var vector = await embedder.GenerateVectorAsync(query, cancellationToken: ct);
return await client.QueryAsync(
_opt.CollectionName,
prefetch:
[
new PrefetchQuery
{
Query = vector.ToArray(),
Using = _opt.DenseVectorName,
Limit = _opt.PrefetchLimit,
},
new PrefetchQuery
{
Query = new Document { Model = Bm25Model, Text = query },
Using = _opt.SparseVectorName,
Limit = _opt.PrefetchLimit,
},
],
query: Fusion.Rrf,
limit: (ulong)limit,
payloadSelector: true,
cancellationToken: ct);
}Hybrid Queries · Qdrant Docs ↗
🔌 Passo 9 · Registrare tutto con AddHybridSearch
Raccolgo il wiring in una extension method: opzioni tipizzate, il client gRPC di Qdrant come singleton, i due client Ollama (embedding e chat) registrati tramite le astrazioni di Microsoft.Extensions.AI — con Redis come exact-match cache nella pipeline del chat client — e i tre servizi dietro le loro interfacce: store, semantic cache e RagService.
Endpoint e test dipendono solo dalle interfacce: se un domani volessi cambiare database, riscriverei una classe e il resto dell'app non se ne accorgerebbe.
using HybridSearchDemo.Caching;
using HybridSearchDemo.Configuration;
using HybridSearchDemo.Rag;
using HybridSearchDemo.Search;
using Microsoft.Extensions.AI;
using OllamaSharp;
using Qdrant.Client;
namespace HybridSearchDemo.Extensions;
public static class ServiceCollectionExtensions
{
// One call wires the whole hybrid stack: typed options, the Qdrant gRPC
// client, the Ollama embedding + chat clients, the hybrid store, the
// Qdrant semantic cache and the RAG service.
public static IServiceCollection AddHybridSearch(this IServiceCollection services, IConfiguration configuration)
{
var section = configuration.GetSection(HybridSearchOptions.SectionName);
services.Configure<HybridSearchOptions>(section);
var options = section.Get<HybridSearchOptions>() ?? new HybridSearchOptions();
// gRPC client, port 6334. The REST port (6333) only serves the dashboard here.
services.AddSingleton(_ => new QdrantClient(options.QdrantHost, options.QdrantPort));
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 for /ask, outermost first: Redis exact-match cache -> Ollama.
// Paraphrases are caught EARLIER, by the Qdrant semantic cache in RagService.
services.AddStackExchangeRedisCache(redis => redis.Configuration = options.RedisConnection);
services.AddChatClient(new OllamaApiClient(endpoint) { SelectedModel = options.ChatModel })
.UseDistributedCache()
.UseLogging();
services.AddSingleton<IHybridSearchStore, QdrantHybridSearchStore>();
services.AddSingleton<ISemanticCacheStore, QdrantSemanticCacheStore>();
services.AddSingleton<IRagService, RagService>();
return services;
}
}Codice completo · fscamuzzi/hybrid-search-dotnet-qdrant-ollama ↗
🚀 Passo 10 · Program.cs: collection e seed allo startup
Il Program.cs resta compatto: registro i servizi, creo le due collection Qdrant (ricerca e semantic cache), faccio il seed della KB e degli utenti Mongo prima che Kestrel accetti richieste. Tutte le operazioni sono idempotenti: al secondo avvio non succede nulla.
Aspettare i seed prima di servire traffico evita la classica corsa: una ricerca che arriva mentre il corpus è ancora mezzo vuoto, o una risposta /users vuota che finisce in output cache.
using FluentValidation;
using HybridSearchDemo.Caching;
using HybridSearchDemo.Configuration;
using HybridSearchDemo.Endpoints;
using HybridSearchDemo.Extensions;
using HybridSearchDemo.HostedServices;
using HybridSearchDemo.Search;
var builder = WebApplication.CreateBuilder(args);
// Search + RAG side: Qdrant (hybrid search + semantic cache), Ollama
// (embeddings + chat) and the Redis exact-match cache for the chat pipeline.
builder.Services.AddHybridSearch(builder.Configuration);
// Data side: MongoDB (single database, generic IRepository<>), user service,
// FluentValidation and the awaited startup initializer for indexes + 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(HybridSearchOptions.SectionName)[nameof(HybridSearchOptions.RedisConnection)]!;
builder.Services.AddStackExchangeRedisOutputCache(options => options.Configuration = redisConnection);
builder.Services.AddOutputCache();
var app = builder.Build();
// Create the two Qdrant collections (search + semantic cache) and seed the KB
// corpus BEFORE Kestrel accepts requests. Every operation is idempotent.
var store = app.Services.GetRequiredService<IHybridSearchStore>();
await store.EnsureCollectionAsync(app.Lifetime.ApplicationStopping);
await store.SeedFromFileAsync(
Path.Combine(AppContext.BaseDirectory, "Data", "seed-documents.json"),
app.Lifetime.ApplicationStopping);
await app.Services.GetRequiredService<ISemanticCacheStore>()
.EnsureCollectionAsync(app.Lifetime.ApplicationStopping);
// 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.MapSearchEndpoints(); // GET /search -> dense | keyword | hybrid
app.MapAskEndpoints(); // POST /ask -> RAG + Qdrant semantic cache
app.MapUserEndpoints(); // GET /users -> Mongo + Redis output cache
app.MapCacheEndpoints(); // DELETE /cache/output -> flush the output cache
app.Run();Codice completo · fscamuzzi/hybrid-search-dotnet-qdrant-ollama ↗
📡 Passo 11 · L'endpoint /search
Un solo endpoint, GET /search, con binding tipizzato via [AsParameters]. FluentValidation rifiuta query vuote, modalità sconosciute e limiti fuori range con HTTP 400 prima di toccare Ollama o Qdrant; le richieste valide passano allo store e tornano in un DTO con query, modalità, millisecondi e risultati.
La mode resta una stringa nel contratto — così qualsiasi maiuscola/minuscola funziona — e diventa enum solo dopo la validazione. Il tempo trascorso nella risposta rende visibile il costo di ogni strategia.
using System.Diagnostics;
using FluentValidation;
using HybridSearchDemo.Models;
using HybridSearchDemo.Search;
namespace HybridSearchDemo.Endpoints;
public static class SearchEndpoints
{
// GET /search?q=...&mode=dense|keyword|hybrid&limit=5 — one endpoint, three
// strategies, same JSON shape, so the differences are easy to eyeball.
public static void MapSearchEndpoints(this IEndpointRouteBuilder app) =>
app.MapGet("/search", async (
[AsParameters] SearchRequest request,
IValidator<SearchRequest> validator,
IHybridSearchStore store,
CancellationToken ct) =>
{
var validation = await validator.ValidateAsync(request, ct);
if (!validation.IsValid)
return Results.ValidationProblem(validation.ToDictionary());
var mode = request.ParseMode();
// Time the whole call so the reply shows what each strategy costs.
var stopwatch = Stopwatch.StartNew();
var hits = await store.SearchAsync(request.Q, mode, request.Limit, ct);
stopwatch.Stop();
return Results.Ok(new SearchResponse(
request.Q, mode.ToString().ToLowerInvariant(), stopwatch.ElapsedMilliseconds, hits));
});
}Codice completo · fscamuzzi/hybrid-search-dotnet-qdrant-ollama ↗
🧪 Passo 12 · Provarlo da terminale
Quando il pull del modello è finito, dalla cartella HybridSearchDemo avvio l'app con `dotnet run --urls http://localhost:5080`. Tre curl raccontano la storia: sul codice NB-1042 la ricerca densa arranca, il BM25 lo inchioda, e sulla query mista l'ibrido mette il documento giusto al primo posto tenendosi i vicini semantici subito dietro.
# 1) codice esatto, modalita' densa: il documento NB-1042 non emerge
$ curl -s "localhost:5080/search?q=NB-1042&mode=dense"
# 2) stesso codice, BM25: primo risultato, senza nessun embedding
$ curl -s "localhost:5080/search?q=NB-1042&mode=keyword"
# 3) query mista, ibrido: esatto al primo posto + vicini semantici
$ curl -s "localhost:5080/search?q=NB-1042+signature+check+fails&mode=hybrid"
# bonus: parafrasi senza parole in comune — qui vince il denso
$ curl -s "localhost:5080/search?q=second+factor+login&mode=keyword"
$ curl -s "localhost:5080/search?q=second+factor+login&mode=hybrid"Codice completo · fscamuzzi/hybrid-search-dotnet-qdrant-ollama ↗
📈 Dense, keyword e hybrid a confronto
Ecco le tre risposte affiancate. La densa capisce le parafrasi ma perde il codice; il BM25 inchioda il codice ma ignora i sinonimi; l'ibrido tiene il meglio di entrambe nella stessa classifica. E si vede anche il costo: il BM25 puro non paga l'embedding.
Il denso perde il codice, il keyword perde i sinonimi, l'ibrido tiene entrambi.
🤖 Passo 13 · Da retrieval a RAG: POST /ask
Il retrieval da solo non è RAG: manca la G. L'endpoint POST /ask chiude il cerchio: i documenti recuperati da Qdrant non tornano al client — finiscono nel prompt di llama3.1 (via IChatClient di Microsoft.Extensions.AI), e al client torna la risposta del modello, ancorata a quel contesto, insieme alle fonti usate per generarla.
Il flusso di RagService è in cinque mosse, con un dettaglio di efficienza: la domanda viene embeddata una volta sola e quel vettore serve sia da chiave della semantic cache sia da ramo denso del retrieval ibrido — per questo IHybridSearchStore espone un overload che accetta un embedding già calcolato.
public async Task<AskReply> AskAsync(string question, CancellationToken ct = default)
{
var stopwatch = Stopwatch.StartNew();
// 1) One embedding, two uses: semantic-cache key AND dense retrieval branch.
var embedding = await embedder.GenerateVectorAsync(question, cancellationToken: ct);
// 2) Cache probe: a semantically-close question already answered within the
// TTL short-circuits everything — no retrieval, no LLM call.
var cached = await cacheStore.FindAsync(embedding, ct);
if (cached is not null)
return new AskReply(cached, "semantic-cache", stopwatch.ElapsedMilliseconds, []);
// 3) Retrieval: hybrid (dense + BM25 + RRF) over the KB collection.
var hits = await searchStore.HybridSearchAsync(embedding, question, _opt.RagTopK, ct);
// 4) Generation: the retrieved chunks become the grounding context of a
// single prompt; the model must answer from them or say it cannot.
var response = await chat.GetResponseAsync(BuildPrompt(question, hits), cancellationToken: ct);
// 5) Persist for the next paraphrase — only non-blank answers.
if (!string.IsNullOrWhiteSpace(response.Text))
await cacheStore.SaveAsync(embedding, question, response.Text, ct);
stopwatch.Stop();
return new AskReply(response.Text, "model", stopwatch.ElapsedMilliseconds,
[.. hits.Select(hit => new RagSource(hit.Id, hit.Score, hit.Title))]);
}Codice completo · fscamuzzi/hybrid-search-dotnet-qdrant-ollama ↗
🧠 Passo 14 · La semantic cache delle risposte, su Qdrant
Nell'articolo sul semantic caching la cache viveva su Redis; qui la sposto su Qdrant, che ho già in casa: una seconda collection (semantic_cache) con un solo vettore denso. Ogni risposta del modello diventa un punto: vettore = embedding della domanda, payload = domanda, risposta e createdAt. Una domanda nuova è un hit se il suo vicino più prossimo supera similarità coseno 0,85.
Qdrant non ha TTL nativo: la scadenza la impongo a query time, filtrando createdAt con una condizione range su un payload index dedicato — un'entrata più vecchia del TTL è invisibile anche se il vettore combacia perfettamente. Risultato: la parafrasi di una domanda già fatta non tocca mai il modello.
// One nearest neighbour, gated by similarity AND freshness: an entry older
// than CacheTtl is invisible even when its vector is a perfect match.
public async Task<string?> FindAsync(ReadOnlyMemory<float> embedding, CancellationToken ct = default)
{
var oldestValid = DateTimeOffset.UtcNow.Subtract(_opt.CacheTtl).ToUnixTimeSeconds();
var hits = await client.QueryAsync(
_opt.CacheCollectionName,
query: embedding.ToArray(),
filter: Conditions.Range("createdAt", new Range { Gte = oldestValid }),
limit: 1,
scoreThreshold: _opt.CacheMinScore, // cosine similarity >= 0.85
payloadSelector: true,
cancellationToken: ct);
return hits.Count > 0 ? hits[0].Payload["answer"].StringValue : null;
}
// Stores a fresh question/answer pair keyed by the question embedding.
public async Task SaveAsync(
ReadOnlyMemory<float> embedding, string question, string answer, CancellationToken ct = default)
{
var point = new PointStruct
{
Id = Guid.NewGuid(),
Vectors = embedding.ToArray(),
Payload =
{
["question"] = question,
["answer"] = answer,
["createdAt"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
},
};
await client.UpsertAsync(_opt.CacheCollectionName, [point], cancellationToken: ct);
}Codice completo · fscamuzzi/hybrid-search-dotnet-qdrant-ollama ↗
⚡ Passo 15 · Redis: exact-match e output cache
Redis in questo progetto fa due lavori, nessuno dei quali è la semantic cache (che sta su Qdrant). Primo: è il distributed cache dietro UseDistributedCache() nella pipeline del chat client — un prompt RAG byte-identico (stessa domanda, stesso contesto recuperato) non raggiunge mai il modello due volte. Secondo: è l'output cache di ASP.NET Core per GET /users — l'intera risposta HTTP vive su Redis per 60 secondi, variata per query string e taggata users così DELETE /cache/output la può evincere in blocco.
I tre livelli di caching hanno ruoli diversi e si compongono senza pestarsi: output cache (risposta HTTP identica), exact-match (prompt identico), semantic (domanda simile).
// Chat pipeline for /ask, outermost first: Redis exact-match cache -> Ollama.
// Paraphrases are caught EARLIER, by the Qdrant semantic cache in RagService.
services.AddStackExchangeRedisCache(redis => redis.Configuration = options.RedisConnection);
services.AddChatClient(new OllamaApiClient(endpoint) { SelectedModel = options.ChatModel })
.UseDistributedCache()
.UseLogging();
// GET /users — the WHOLE HTTP response is cached on Redis for 60 seconds,
// varied by the paging params and tagged for one-shot eviction.
app.MapGet("/users", async ([AsParameters] GetUsersRequest request, ...) => { ... })
.CacheOutput(policy => policy
.Expire(TimeSpan.FromSeconds(60))
.SetVaryByQuery("page", "pageSize", "search")
.Tag("users"));
// DELETE /cache/output — evict every cached /users response at once.
app.MapDelete("/cache/output", async (IOutputCacheStore store, CancellationToken ct) =>
{
await store.EvictByTagAsync("users", ct);
return Results.NoContent();
});Codice completo · fscamuzzi/hybrid-search-dotnet-qdrant-ollama ↗
🗄️ Passo 16 · MongoDB: IRepository generico, Bogus e paginazione
Il lato dati riusa il pattern del mio articolo sul semantic caching: un database MongoDB, un repository generico IRepository<T> — niente classi repository per-collection — e un initializer atteso allo startup che crea gli indici e semina 500 utenti finti con Bogus. GET /users pagina lato server: filtro come expression LINQ tipizzata, CountDocumentsAsync e pagina ordinata partono in parallelo, e la risposta è un envelope tipizzato con items, pagina e totali.
Nota su LINQ: il driver MongoDB ha un provider LINQ vero, e il repository lo sfrutta (IQueryable, predicati tipizzati, Regex.IsMatch tradotto in $regex). Qdrant.Client no — non esiste un provider LINQ per Qdrant, e la sua Query API gRPC (prefetch, Rrf, filtri) è la superficie idiomatica in .NET: usarla direttamente non è un ripiego, è la scelta giusta.
public async Task<PagedResult<UserResponse>> GetPagedAsync(GetUsersRequest request, CancellationToken ct = default)
{
// 1) Typed LINQ expression: blank search matches everything, otherwise the
// Regex.Escaped term is matched (case-insensitive $regex) on three fields.
var search = request.Search;
Expression<Func<User, bool>> predicate;
if (string.IsNullOrWhiteSpace(search))
{
predicate = _ => true;
}
else
{
var pattern = Regex.Escape(search);
predicate = u => Regex.IsMatch(u.LastName, pattern, RegexOptions.IgnoreCase) ||
Regex.IsMatch(u.FirstName, pattern, RegexOptions.IgnoreCase) ||
Regex.IsMatch(u.Email, pattern, RegexOptions.IgnoreCase);
}
// 2) Server-side paging — the repository issues count + sorted page in
// parallel and returns the filtered total alongside the 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);
}Codice completo · fscamuzzi/hybrid-search-dotnet-qdrant-ollama ↗
🎚️ Tuning: le manopole che contano
La hybrid search di base funziona senza tuning, ma tre manopole meritano attenzione. Il PrefetchLimit decide quanti candidati per ramo arrivano alla fusione: troppo basso e l'RRF non ha materiale, troppo alto e paghi latenza inutile — 20-50 è un buon intervallo. La k dell'RRF (default 2 in Qdrant) appiattisce o accentua il peso delle prime posizioni. E dalla versione 1.16 Qdrant supporta anche i pesi per ramo nell'RRF, se vuoi spingere il lato semantico o quello lessicale.
L'alternativa DBSF normalizza i punteggi (media ± 3 deviazioni standard) e li somma: può premiare margini di distacco che l'RRF ignora, ma è più sensibile alla distribuzione dei punteggi del tuo corpus. Il mio consiglio pratico: parti con RRF liscia, misura su un set di query reali, e cambia solo se i numeri lo giustificano.
RRF (default)
- Fonde per posizione, ignora i punteggi
- Zero normalizzazione richiesta
- Robusta tra metriche diverse
- k e pesi per ramo regolabili
DBSF
- Normalizza e somma i punteggi
- Sensibile alla distribuzione
- Premia i distacchi netti
- Da validare sul proprio corpus
Parti con RRF; passa a DBSF solo se un eval set sul tuo dominio mostra un guadagno reale.
✅ Checklist finale
Ricapitolo. Se hai seguito i passi, ora hai una Minimal API che interroga la stessa base di conoscenza in tre modi, fonde vettori densi e keyword BM25 dentro il database, e soprattutto chiude il giro della RAG: i documenti recuperati alimentano llama3.1 e la risposta del modello torna al client con le fonti, passando da una semantic cache su Qdrant, dall'exact-match su Redis e con gli utenti Mongo paginati e output-cached.
Da qui puoi salire di livello: aggiungere i payload filter di Qdrant (categoria, lingua, tenant), provare i pesi RRF o un re-ranker sui primi risultati fusi, o sostituire il corpus finto con i tuoi documenti veri.
- 01Docker ComposeQdrant, Ollama (2 modelli), Redis, MongoDB
- 02Progetto + NuGetExtensions.AI, Qdrant.Client, Mongo, Redis
- 03appsettings + Optionsdue vettori nominati tipizzati
- 04Collectiondense coseno + sparse IDF
- 05Ingestionembedding + Document qdrant/bm25
- 06Tre ricerchedense, keyword, hybrid
- 07Prefetch + RRFfusione dentro Qdrant
- 08Endpoint /searchvalidazione + DTO con i tempi
- 09RAG: POST /askretrieval → llama3.1 → risposta + fonti
- 10Semantic cacheseconda collection Qdrant, TTL a filtro
- 11Redisexact-match + output cache di /users
- 12MongoDBIRepository, Bogus, paginazione
Da qui: payload filter, pesi RRF, re-ranking, il tuo corpus reale.
📦 Il progetto completo su GitHub
Il progetto companion completo di questo tutorial — store Qdrant, le tre modalità di ricerca, l'endpoint RAG con semantic cache, Redis per exact-match e output cache, MongoDB con repository generico e utenti Bogus, il corpus di seed con i codici NB-*, validazione FluentValidation, file .http con tutte le chiamate pronte, Swagger UI e ReDoc su /swagger e /redoc, e una suite di unit test xUnit che gira senza nessuna infrastruttura (né Qdrant, né Ollama, né Redis, né MongoDB) — è pubblico su GitHub, con licenza MIT. Il README riporta lo stesso flusso Docker Compose, `dotnet test` e `dotnet run` mostrato qui, più uno schema dell'architettura.
git clone https://github.com/fscamuzzi/hybrid-search-dotnet-qdrant-ollama.git
cd hybrid-search-dotnet-qdrant-ollama
docker compose up -d # Qdrant + Ollama
docker compose logs -f ollama-init # attendi exit code 0
cd HybridSearchDemo && dotnet run --urls http://localhost:5080fscamuzzi/hybrid-search-dotnet-qdrant-ollama · GitHub ↗
📞 Le chiamate di esempio, dal file .http
Chiudo con le chiamate che raccontano tutto il sistema — sono le stesse del file HybridSearchDemo.http nel repo, pronte per il REST Client di VS Code o l'HTTP client di Rider. La sequenza da provare in ordine: le tre ricerche sul codice NB-1042, poi la RAG — prima domanda con `"source": "model"` e le fonti, parafrasi con `"source": "semantic-cache"` e elapsedMs che crolla senza toccare il modello — e infine gli utenti paginati, dove la seconda chiamata identica esce dall'output cache di Redis.
Ogni risposta è un DTO tipizzato con i millisecondi: il confronto dei tempi tra `model`, `semantic-cache` e output cache è la dimostrazione più concreta del perché questi tre livelli esistono.
### Search: dense vs keyword vs hybrid sullo stesso codice
GET {{host}}/search?q=NB-1042&mode=dense
GET {{host}}/search?q=NB-1042&mode=keyword
GET {{host}}/search?q=NB-1042 signature check fails&mode=hybrid
### RAG: prima domanda -> "source": "model" + fonti
POST {{host}}/ask
Content-Type: application/json
{ "question": "Why does NB-1042 keep failing on my webhook endpoint?" }
### Parafrasi -> "source": "semantic-cache", nessuna chiamata LLM
POST {{host}}/ask
Content-Type: application/json
{ "question": "My webhook endpoint keeps getting the NB-1042 failure, what is wrong?" }
### Utenti: paginazione Mongo + output cache Redis (60s, tag "users")
GET {{host}}/users?page=1&pageSize=10
GET {{host}}/users?page=1&pageSize=5&search=an
### Svuota l'output cache: la prossima /users torna fresca da Mongo
DELETE {{host}}/cache/outputCodice completo · fscamuzzi/hybrid-search-dotnet-qdrant-ollama ↗
🎁 Bonus · Swagger UI e ReDoc: l'API che si documenta da sola
Come bonus, l'API espone la propria documentazione con un solo documento OpenAPI (Swashbuckle) e due interfacce: /swagger (Swagger UI, la console interattiva col Try it out) e /redoc (ReDoc, la reference a tre pannelli). A nutrire il documento sono i commenti XML (`///`) su endpoint, request e response record — quello che leggi in ReDoc è lo stesso testo che leggi nel codice — più i metadati degli endpoint (WithSummary, WithTags, Produces<T>), che raggruppano le operazioni in Search, RAG, Users e Cache.
Due dettagli che fanno la differenza in ReDoc. Primo: required vs optional sul serio — SupportNonNullableReferenceTypes + NonNullableReferenceTypesAsRequired rendono required le proprietà non-nullable, e gli attributi [Required], [Range] e [MaxLength] marcano il resto: `q` e `question` compaiono come required, `mode`, `limit`, `page`, `pageSize` e `search` come opzionali con default e limiti. Secondo: un operation filter inietta l'estensione x-codeSamples con snippet pronti da copiare in curl, C#, JavaScript e Python — ReDoc li mostra come tab di linguaggio accanto a ogni operazione.
// SwaggerGen: XML comments + required dalla nullability + code samples.
services.AddSwaggerGen(options =>
{
options.SwaggerDoc("v1", new OpenApiInfo { Title = "Hybrid Search + RAG API", Version = "v1" });
// Non-nullable (string) => required nello schema; string? resta optional.
options.SupportNonNullableReferenceTypes();
options.NonNullableReferenceTypesAsRequired();
// I commenti /// di questo assembly diventano le descrizioni di schema e campi.
options.IncludeXmlComments(Path.Combine(AppContext.BaseDirectory, "HybridSearchDemo.xml"));
options.OperationFilter<CodeSamplesOperationFilter>();
});
// Due UI sullo stesso documento: /swagger (try-it-out) e /redoc (reference).
app.UseSwagger();
app.UseSwaggerUI(ui => ui.SwaggerEndpoint("/swagger/v1/swagger.json", "Hybrid Search + RAG API v1"));
app.UseReDoc(redoc => { redoc.RoutePrefix = "redoc"; redoc.SpecUrl = "/swagger/v1/swagger.json"; });
// L'operation filter: tab di codice per ReDoc, un set per endpoint.
operation.Extensions["x-codeSamples"] = new OpenApiArray
{
new OpenApiObject
{
["lang"] = new OpenApiString("Shell"),
["label"] = new OpenApiString("curl"),
["source"] = new OpenApiString("curl -s -X POST http://localhost:5080/ask ..."),
},
// ... C#, JavaScript, Python
};
// Sulle request, la nullability e gli attributi decidono required/optional:
public sealed record AskRequest(
[property: Required, MaxLength(500), Description("The question. REQUIRED.")]
string Question);Swashbuckle.AspNetCore · GitHub ↗
Domande frequenti su hybrid search in .NET
Che cos'è la hybrid search?
È una ricerca che esegue la stessa query su due rappresentazioni — vettori densi (embedding, semantica) e vettori sparsi (keyword BM25, termini esatti) — e fonde le due classifiche in una sola, tipicamente con la Reciprocal Rank Fusion. Prende il meglio di entrambe: parafrasi e codici esatti.
Perché non basta la ricerca vettoriale densa?
Perché gli embedding rappresentano male i token rari e opachi: codici di errore, SKU, sigle. Una query come «NB-1042» per il modello è una stringa senza semantica, e il documento giusto può non emergere. BM25, grazie all'IDF, premia proprio i termini rari.
Che cos'è la Reciprocal Rank Fusion (RRF)?
Un metodo di fusione che ignora i punteggi grezzi e usa solo le posizioni: ogni documento riceve la somma di 1/(k + rank) per ciascuna classifica in cui appare. Non richiede normalizzazione tra metriche con scale diverse, come coseno e BM25. In Qdrant k vale 2 e i rank partono da zero.
Devo calcolare io i vettori BM25 in C#?
No. Da Qdrant 1.15.2 la conversione in vettori sparsi BM25 avviene lato server: passo un Document con il testo e il modello qdrant/bm25, sia in ingestion sia in query. Serve solo dichiarare il modificatore IDF sulla configurazione del vettore sparso della collection.
Serve un modello di chat per la hybrid search?
Per la sola ricerca no: basta un modello di embedding (qui nomic-embed-text via Ollama, 768 dimensioni). Il modello di chat (llama3.1) entra in gioco nell'endpoint /ask, dove i documenti recuperati diventano il contesto del prompt e la risposta generata torna al client: è la parte RAG del tutorial.
Meglio RRF o DBSF?
RRF è il default robusto: fonde per posizione e non dipende dalla scala dei punteggi. DBSF normalizza i punteggi e li somma: può premiare margini netti ma è più sensibile alla distribuzione del corpus. Consiglio di partire con RRF e cambiare solo dopo una misura su query reali.
Posso usare questo approccio per la RAG?
Sì, e in questo tutorial lo faccio davvero: POST /ask esegue il retrieval ibrido, passa i chunk recuperati a llama3.1 come contesto del prompt e restituisce al client la risposta generata dal modello insieme alle fonti. Il retrieval da solo non è RAG: serve anche la generazione.
Come funziona la semantic cache su Qdrant?
Le risposte del modello finiscono in una seconda collection Qdrant: vettore = embedding della domanda, payload = domanda, risposta e createdAt. Una nuova domanda è un hit se il vicino più prossimo supera similarità coseno 0,85 ed è più recente del TTL, imposto a query time con un filtro range su un payload index. Le parafrasi non toccano mai il modello.
A cosa serve Redis se la semantic cache sta su Qdrant?
A due cose diverse: è il distributed cache exact-match davanti al chat client (un prompt identico non raggiunge mai il modello due volte) ed è l'output cache di ASP.NET Core per GET /users, che tiene l'intera risposta HTTP per 60 secondi, variata per query string e taggata per l'eviction con DELETE /cache/output.
Esiste un provider LINQ per Qdrant in .NET?
No. Il driver MongoDB ha un provider LINQ vero e il repository generico lo usa (IQueryable, predicati tipizzati, $regex). Qdrant.Client invece espone la Query API gRPC nativa — prefetch, Rrf, filtri — che è la superficie idiomatica in .NET: non esiste un'alternativa LINQ da preferire.
Parliamone
Se questo tema ti riguarda, scrivimi: confrontarsi su codice e AI è sempre tempo speso bene.