RAG för implementatorer: processorvänlig semantisk sökning med ONNX och Qdrant
Inledning
En del av RAG-serien: Detta är del 4a – centralt genomförande:
- Del 1: RAG:s ursprung och grundförutsättningar - Vad inbäddningar är, varför de betyder något
- Del 2: RAG:s arkitektur och interner - Chunking, tokenisering, vektordatabaser
- Del 3: RAG i praktiken - Bygga kompletta RAG-system
- Del 4a: Genomförande av ONNX och Qdrant (den här artikeln) - CPU-vänlig semantisk sökfundament
- Del 4b: Semantisk sökning i verksamhet - Typeahead-, hybridsöknings- och användargränssnittskomponenter
- Del 5: Hybridsökning och automatisk indexning - Produktionsintegrationsmönster
- Del 6: GraphRAG - Kunskapsgrafer för corpus-nivåförståelse
Del 1-3 förklarar varför Semantisk sökning fungerar. Den här artikeln visar hur för att bygga grunden - en nollkostnad, CPU-vänligt genomförande använder ONNX Runtime och Qdrant. Häfte 4b täcker sökningen UI och hybrid sökning genomförande, och Häfte 5 omfattar automatisk indexering av produktionen.
Utmaningen: De flesta semantiska söklösningar kräver dyr GPU-infrastruktur eller dyra hanterade tjänster. Tänk om du är en indieutvecklare som kör en blogg på en blygsam VPS?
Lösningen: Ett fullt fungerande semantiskt söksystem som körs helt på CPU, med gratis öppen källkod verktyg. Detta är den exakta inställningen som körs på denna blogg - noll extra kostnad utöver befintliga hosting.
Grundläggande begrepp
Dessa begrepp behandlas ingående i RAG-serien, men här är vad du behöver veta för detta genomförande:
Inbäddningar: Text som siffror
Inbäddningar är vektorer (arrayer av nummer) som fångar betydelse Liknande betydelser producerar liknande vektorer - det är magin.
graph TD
A["Text: 'The cat sat on the mat'"] --> B[Embedding Model]
B --> C["Vector: [0.25, -0.18, 0.91, ... 384 more numbers]"]
D["Text: 'A feline rested on the carpet'"] --> B
B --> E["Vector: [0.27, -0.16, 0.89, ... similar numbers!]"]
C -.Similar vectors = similar meaning.-> E
style A stroke:#10b981,stroke-width:2px
style D stroke:#10b981,stroke-width:2px
style B stroke:#6366f1,stroke-width:3px
style C stroke:#f59e0b,stroke-width:2px
style E stroke:#f59e0b,stroke-width:2px
Nyckelinsikt: Texter med liknande betydelser kommer att ha liknande vektorer (inbäddningar). Så här kan vi hitta "relaterat" innehåll - vi mäter bokstavligen avståndet mellan betydelser!
Förstå Cosine likhet
Kosinglikvärdighet mäter vinkeln mellan två vektorer - om de pekar i liknande riktningar, är de semantiskt likartade:
flowchart LR
subgraph "Vector Space (simplified to 2D)"
direction TB
A["'Docker tutorial'"] -.-> B((0.85))
C["'Container deployment'"] -.-> B
D["'Cooking recipes'"] -.-> E((0.12))
A -.-> E
end
B --> F["High Similarity<br/>Related content!"]
E --> G["Low Similarity<br/>Different topics"]
style A stroke:#10b981,stroke-width:2px
style C stroke:#10b981,stroke-width:2px
style D stroke:#f59e0b,stroke-width:2px
style B stroke:#22c55e,stroke-width:3px
style E stroke:#ef4444,stroke-width:3px
style F stroke:#22c55e,stroke-width:2px
style G stroke:#ef4444,stroke-width:2px
Formeln: similarity = (A · B) / (||A|| × ||B||) - men eftersom vi L2-normaliserar våra vektorer, förenklar det till bara punkt produkt!
Vad är ONNX?
ONNX (Open Neural Network Exchange) är ett öppet standardformat för maskininlärning modeller som gör det möjligt för dem att köra effektivt över olika plattformar. Tänk på det som en universell översättare för AI-modeller. ONNX körtid är Microsofts högpresterande inference motor som kör dessa modeller.
Varför ONNX för vårt användningsfall:
- Körs på CPU (ingen GPU behövs!) - se ONNX körtid CPU exekvering leverantör dokument
- Mycket snabbare än att köra modeller i Python
- Mindre minnesavtryck
- Integrerar sömlöst med .NET via Microsoft.ML.OnnxRuntime NuGet-paket
- Stöd Grafoptimering för snabbare slutledning
flowchart LR
subgraph "ONNX Inference Pipeline"
A[Raw Text] --> B[Tokenizer]
B --> C["Tokens: [CLS] the cat sat [SEP]"]
C --> D[Token IDs: 101 1996 4937 2068 102]
D --> E[ONNX Runtime]
E --> F[384-dim Vector]
F --> G[L2 Normalize]
G --> H[Final Embedding]
end
style A stroke:#10b981,stroke-width:2px
style B stroke:#f59e0b,stroke-width:2px
style C stroke:#f59e0b,stroke-width:2px
style D stroke:#f59e0b,stroke-width:2px
style E stroke:#6366f1,stroke-width:3px
style F stroke:#8b5cf6,stroke-width:2px
style G stroke:#8b5cf6,stroke-width:2px
style H stroke:#ef4444,stroke-width:2px
Vad är Qdrant?
Qdrant Ordförande är en vektordatabas med öppen källkod - en databas som är optimerad för lagring och sökning av dessa inbäddade vektorer. För en djupdykning i Qdrants koncept, konfiguration och C# integration, se Självstämplade Vektordatabaser med Qdrant. Medan du kunde lagra vektorer i PostgreSQL, Qdrant är avsedd för detta och erbjuder:
- Blixtsnabb likhetssökning med hjälp av HNSW-algoritm
- Filtrering av metadata - filterresultat genom nyttolastfält
- Skalbarhet till miljontals vektorer med distribuerad utplacering
- Låg resursanvändning - körs bekvämt på blygsam hårdvara
- Självvärdig med Docker - se Qdrant Docker snabbstart
- gRPC och REST API:er för integration
- Native .NET stöd via Qdrant.Client NuGet paket
flowchart TB
subgraph "Qdrant Vector Storage"
direction TB
A[Collection: blog_posts] --> B[Point 1]
A --> C[Point 2]
A --> D[Point N...]
B --> B1["Vector: [0.12, -0.08, ...]"]
B --> B2["Payload: {slug, title, language}"]
C --> C1["Vector: [0.25, 0.14, ...]"]
C --> C2["Payload: {slug, title, language}"]
end
subgraph "Vector Search"
E[Query Vector] --> F[HNSW Index]
F --> G[Cosine Similarity]
G --> H[Top K Results]
end
style A stroke:#ef4444,stroke-width:3px
style B stroke:#8b5cf6,stroke-width:2px
style C stroke:#8b5cf6,stroke-width:2px
style D stroke:#8b5cf6,stroke-width:2px
style B1 stroke:#f59e0b,stroke-width:2px
style B2 stroke:#10b981,stroke-width:2px
style C1 stroke:#f59e0b,stroke-width:2px
style C2 stroke:#10b981,stroke-width:2px
style E stroke:#6366f1,stroke-width:2px
style F stroke:#ec4899,stroke-width:3px
style G stroke:#ec4899,stroke-width:2px
style H stroke:#10b981,stroke-width:2px
Översikt över arkitekturen
Så här passar vårt semantiska söksystem ihop:
flowchart TB
subgraph "Content Ingestion"
A[Blog Post Markdown] --> B[Extract Plain Text]
B --> C[ONNX Embedding Service]
C --> D[Generate 384-dim Vector]
D --> E[Qdrant Vector Store]
end
subgraph "Search Flow"
F[User Query] --> G[ONNX Embedding Service]
G --> H[Generate Query Vector]
H --> I[Qdrant Search]
E -.Vector Similarity.-> I
I --> J[Ranked Results]
end
subgraph "Related Posts"
K[Current Blog Post] --> L[Get Post Vector from Qdrant]
L --> M[Find Similar Vectors]
E -.->M
M --> N[Top 5 Related Posts]
end
style A stroke:#10b981,stroke-width:2px
style B stroke:#10b981,stroke-width:2px
style C stroke:#6366f1,stroke-width:3px
style D stroke:#f59e0b,stroke-width:2px
style E stroke:#ef4444,stroke-width:3px
style F stroke:#10b981,stroke-width:2px
style G stroke:#6366f1,stroke-width:3px
style H stroke:#f59e0b,stroke-width:2px
style I stroke:#ef4444,stroke-width:2px
style J stroke:#8b5cf6,stroke-width:2px
style K stroke:#10b981,stroke-width:2px
style L stroke:#ef4444,stroke-width:2px
style M stroke:#ef4444,stroke-width:2px
style N stroke:#8b5cf6,stroke-width:2px
Flödet på klar engelska:
- Indexering: När du skriver ett blogginlägg konverterar vi det till en vektor och lagrar det i Qdrant
- Söker: När någon söker konverterar vi deras fråga till en vektor och hittar liknande vektorer i Qdrant
- Relaterade inlägg: För alla blogginlägg kan vi hitta andra inlägg med liknande vektorer
Projektstruktur
Vi har skapat en ren, modulär struktur:
Mostlylucid.SemanticSearch/
├── Config/
│ └── SemanticSearchConfig.cs # Configuration settings
├── Models/
│ ├── BlogPostDocument.cs # Document model for indexing
│ └── SearchResult.cs # Search result model
├── Services/
│ ├── IEmbeddingService.cs # Embedding interface
│ ├── OnnxEmbeddingService.cs # ONNX-based embeddings
│ ├── IVectorStoreService.cs # Vector store interface
│ ├── QdrantVectorStoreService.cs # Qdrant implementation
│ ├── ISemanticSearchService.cs # High-level search interface
│ └── SemanticSearchService.cs # Orchestration service
├── Extensions/
│ └── ServiceCollectionExtensions.cs # DI registration
├── download-models.sh # Model download script
└── README.md
Genomförande
Steg 1: Sätta upp projektet
Först, skapa det nya klassbiblioteket:
dotnet new classlib -n Mostlylucid.SemanticSearch -f net9.0
dotnet sln add Mostlylucid.SemanticSearch
Lägg till nödvändiga NuGet-paket:
cd Mostlylucid.SemanticSearch
dotnet add package Microsoft.Extensions.Logging.Abstractions
dotnet add package Microsoft.ML.OnnxRuntime --version 1.21.1
dotnet add package Qdrant.Client --version 1.14.0
dotnet add reference ../Mostlylucid.Shared/Mostlylucid.Shared.csproj
Steg 2: Inställning
Vi sätter upp vår konfigurationsklass. IConfigSection Mönster som används i Mostlylucid:
using Mostlylucid.Shared.Config;
namespace Mostlylucid.SemanticSearch.Config;
/// <summary>
/// Configuration for semantic search functionality
/// </summary>
public class SemanticSearchConfig : IConfigSection
{
public static string Section => "SemanticSearch";
/// <summary>
/// Enable or disable semantic search
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Qdrant server URL (e.g., http://localhost:6333)
/// </summary>
public string QdrantUrl { get; set; } = "http://localhost:6333";
/// <summary>
/// Optional read-only API key for Qdrant (used for search operations)
/// </summary>
public string? ReadApiKey { get; set; }
/// <summary>
/// Optional read-write API key for Qdrant (used for indexing operations)
/// </summary>
public string? WriteApiKey { get; set; }
/// <summary>
/// Collection name in Qdrant for blog posts
/// </summary>
public string CollectionName { get; set; } = "blog_posts";
/// <summary>
/// Path to the ONNX embedding model file
/// </summary>
public string EmbeddingModelPath { get; set; } = "models/all-MiniLM-L6-v2.onnx";
/// <summary>
/// Path to the tokenizer vocabulary file
/// </summary>
public string VocabPath { get; set; } = "models/vocab.txt";
/// <summary>
/// Embedding vector size (384 for all-MiniLM-L6-v2)
/// </summary>
public int VectorSize { get; set; } = 384;
/// <summary>
/// Number of related posts to return
/// </summary>
public int RelatedPostsCount { get; set; } = 5;
/// <summary>
/// Minimum similarity score (0-1) for related posts
/// </summary>
public float MinimumSimilarityScore { get; set; } = 0.5f;
/// <summary>
/// Number of search results to return
/// </summary>
public int SearchResultsCount { get; set; } = 10;
}
Varför separera API-tangenter? Säkerhet! Din läsnyckel kan användas i offentliga sökslutpunkter, medan din skrivnyckel bara stannar serversidan för administratörsåtgärder.
Lägg till detta till din appsettings.json:
{
"SemanticSearch": {
"Enabled": false,
"QdrantUrl": "http://localhost:6333",
"ReadApiKey": "",
"WriteApiKey": "",
"CollectionName": "blog_posts",
"EmbeddingModelPath": "models/all-MiniLM-L6-v2.onnx",
"VocabPath": "models/vocab.txt",
"VectorSize": 384,
"RelatedPostsCount": 5,
"MinimumSimilarityScore": 0.5,
"SearchResultsCount": 10
}
}
Steg 3: Inbäddningstjänsten för ONNX
Det är här magin händer. Vi använder all-MiniLM-L6-v2-modellen, som är särskilt utformad för semantiska likhetsuppgifter och kör effektivt på CPU.
Varför denna modell?
- Liten storlek (~90MB)
- Snabb slutsats på CPU (~50-100ms per inbäddning)
- Inbäddningar av god kvalitet (384 dimensioner)
- Utbildad på över 1 miljard mening par
Här är det fullständiga genomförandet:
using Microsoft.Extensions.Logging;
using Microsoft.ML.OnnxRuntime;
using Microsoft.ML.OnnxRuntime.Tensors;
using Mostlylucid.SemanticSearch.Config;
using System.Text.RegularExpressions;
namespace Mostlylucid.SemanticSearch.Services;
public class OnnxEmbeddingService : IEmbeddingService, IDisposable
{
private readonly ILogger<OnnxEmbeddingService> _logger;
private readonly SemanticSearchConfig _config;
private readonly InferenceSession? _session;
private readonly Dictionary<string, int> _vocabulary;
private readonly SemaphoreSlim _semaphore = new(1, 1);
private bool _disposed;
private const int MaxSequenceLength = 256;
private const string PadToken = "[PAD]";
private const string UnkToken = "[UNK]";
private const string ClsToken = "[CLS]";
private const string SepToken = "[SEP]";
public OnnxEmbeddingService(
ILogger<OnnxEmbeddingService> logger,
SemanticSearchConfig config)
{
_logger = logger;
_config = config;
_vocabulary = new Dictionary<string, int>();
if (!_config.Enabled)
{
_logger.LogInformation("Semantic search is disabled");
return;
}
try
{
// Check if model file exists
if (!File.Exists(_config.EmbeddingModelPath))
{
_logger.LogWarning("Embedding model not found at {Path}. Semantic search will be disabled.",
_config.EmbeddingModelPath);
return;
}
// Load vocabulary if it exists
if (File.Exists(_config.VocabPath))
{
LoadVocabulary(_config.VocabPath);
}
// Create ONNX session with CPU execution provider
var sessionOptions = new SessionOptions
{
ExecutionMode = ExecutionMode.ORT_SEQUENTIAL,
GraphOptimizationLevel = GraphOptimizationLevel.ORT_ENABLE_ALL
};
_session = new InferenceSession(_config.EmbeddingModelPath, sessionOptions);
_logger.LogInformation("ONNX embedding model loaded successfully from {Path}",
_config.EmbeddingModelPath);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to initialize ONNX embedding service");
}
}
private void LoadVocabulary(string vocabPath)
{
var lines = File.ReadAllLines(vocabPath);
for (int i = 0; i < lines.Length; i++)
{
var token = lines[i].Trim();
if (!string.IsNullOrEmpty(token))
{
_vocabulary[token] = i;
}
}
_logger.LogInformation("Loaded vocabulary with {Count} tokens", _vocabulary.Count);
}
public async Task<float[]> GenerateEmbeddingAsync(string text, CancellationToken cancellationToken = default)
{
if (_session == null || !_config.Enabled)
{
return new float[_config.VectorSize];
}
if (string.IsNullOrWhiteSpace(text))
{
return new float[_config.VectorSize];
}
// Use semaphore to prevent concurrent ONNX inference (not thread-safe)
await _semaphore.WaitAsync(cancellationToken);
try
{
return await Task.Run(() => GenerateEmbedding(text), cancellationToken);
}
finally
{
_semaphore.Release();
}
}
private float[] GenerateEmbedding(string text)
{
try
{
// Tokenize the input text
var tokens = Tokenize(text);
// Create input tensors for ONNX model
var inputIds = CreateInputTensor(tokens, "input_ids");
var attentionMask = CreateAttentionMaskTensor(tokens.Length);
var tokenTypeIds = CreateTokenTypeIdsTensor(tokens.Length);
// Run inference
var inputs = new List<NamedOnnxValue>
{
NamedOnnxValue.CreateFromTensor("input_ids", inputIds),
NamedOnnxValue.CreateFromTensor("attention_mask", attentionMask),
NamedOnnxValue.CreateFromTensor("token_type_ids", tokenTypeIds)
};
using var results = _session!.Run(inputs);
// Extract the output tensor (sentence embedding)
var output = results.First().AsTensor<float>();
var embedding = output.ToArray();
// Normalize the vector (L2 normalization)
return NormalizeVector(embedding);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error generating embedding for text: {Text}",
text[..Math.Min(100, text.Length)]);
return new float[_config.VectorSize];
}
}
private List<int> Tokenize(string text)
{
// Simple whitespace + punctuation tokenization
var tokens = new List<int>();
// Add [CLS] token at the start
if (_vocabulary.TryGetValue(ClsToken, out var clsId))
tokens.Add(clsId);
// Tokenize the text
var words = Regex.Split(text.ToLowerInvariant(), @"(\W+)")
.Where(w => !string.IsNullOrWhiteSpace(w))
.Take(MaxSequenceLength - 2); // Leave room for [CLS] and [SEP]
foreach (var word in words)
{
if (_vocabulary.Count > 0)
{
if (_vocabulary.TryGetValue(word, out var tokenId))
tokens.Add(tokenId);
else if (_vocabulary.TryGetValue(UnkToken, out var unkId))
tokens.Add(unkId);
}
else
{
// Fallback: use hash code as token ID
tokens.Add(Math.Abs(word.GetHashCode()) % 30000);
}
}
// Add [SEP] token at the end
if (_vocabulary.TryGetValue(SepToken, out var sepId))
tokens.Add(sepId);
return tokens;
}
private Tensor<long> CreateInputTensor(List<int> tokens, string name)
{
var length = Math.Min(tokens.Count, MaxSequenceLength);
var tensorData = new long[1, MaxSequenceLength];
for (int i = 0; i < length; i++)
{
tensorData[0, i] = tokens[i];
}
// Pad the rest
var padId = _vocabulary.TryGetValue(PadToken, out var id) ? id : 0;
for (int i = length; i < MaxSequenceLength; i++)
{
tensorData[0, i] = padId;
}
return new DenseTensor<long>(tensorData, new[] { 1, MaxSequenceLength });
}
private Tensor<long> CreateAttentionMaskTensor(int actualLength)
{
var length = Math.Min(actualLength, MaxSequenceLength);
var tensorData = new long[1, MaxSequenceLength];
for (int i = 0; i < length; i++)
{
tensorData[0, i] = 1; // Attend to actual tokens
}
return new DenseTensor<long>(tensorData, new[] { 1, MaxSequenceLength });
}
private Tensor<long> CreateTokenTypeIdsTensor(int actualLength)
{
var tensorData = new long[1, MaxSequenceLength];
// All zeros for single sentence
return new DenseTensor<long>(tensorData, new[] { 1, MaxSequenceLength });
}
private float[] NormalizeVector(float[] vector)
{
// L2 normalization
var sumOfSquares = vector.Sum(v => v * v);
var magnitude = MathF.Sqrt(sumOfSquares);
if (magnitude > 0)
{
for (int i = 0; i < vector.Length; i++)
{
vector[i] /= magnitude;
}
}
return vector;
}
public void Dispose()
{
if (_disposed) return;
_session?.Dispose();
_semaphore?.Dispose();
_disposed = true;
GC.SuppressFinalize(this);
}
}
Nyckelpunkter för junior devs:
- Tokenisering: Vi bryter text i mindre bitar (tokens) som modellen kan förstå
- Tensorer: Dessa är flerdimensionella matriser som ONNX modeller arbetar med
- Uppmärksamhetsmask: Berättar för modellen vilka delar av indatan som är faktiskt innehåll vs. stoppning
- L2 Normalisering: Gör att alla vektorer har samma "längd", så vi kan jämföra dem rättvist
- Symptom på semaforer: Säkerställer gängsäkerhet (ONNX är inte gängsäker som standard)
Steg 4: Qdrant Vector Store
Låt oss nu genomföra vektorlagringen och söka:
using Microsoft.Extensions.Logging;
using Mostlylucid.SemanticSearch.Config;
using Mostlylucid.SemanticSearch.Models;
using Qdrant.Client;
using Qdrant.Client.Grpc;
namespace Mostlylucid.SemanticSearch.Services;
public class QdrantVectorStoreService : IVectorStoreService
{
private readonly ILogger<QdrantVectorStoreService> _logger;
private readonly SemanticSearchConfig _config;
private readonly QdrantClient? _client;
private bool _collectionInitialized;
public QdrantVectorStoreService(
ILogger<QdrantVectorStoreService> logger,
SemanticSearchConfig config)
{
_logger = logger;
_config = config;
if (!_config.Enabled)
{
_logger.LogInformation("Semantic search is disabled");
return;
}
try
{
var uri = new Uri(_config.QdrantUrl);
var host = uri.Host;
var port = uri.Port > 0 ? uri.Port : 6334; // Default gRPC port
_client = new QdrantClient(host, port, https: uri.Scheme == "https");
_logger.LogInformation("Connected to Qdrant at {Host}:{Port}", host, port);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to connect to Qdrant at {Url}", _config.QdrantUrl);
}
}
public async Task InitializeCollectionAsync(CancellationToken cancellationToken = default)
{
if (_client == null || !_config.Enabled || _collectionInitialized)
return;
try
{
var collections = await _client.ListCollectionsAsync(cancellationToken);
var collectionExists = collections.Any(c => c.Name == _config.CollectionName);
if (!collectionExists)
{
_logger.LogInformation("Creating collection {CollectionName}", _config.CollectionName);
await _client.CreateCollectionAsync(
collectionName: _config.CollectionName,
vectorsConfig: new VectorParams
{
Size = (ulong)_config.VectorSize,
Distance = Distance.Cosine // Cosine similarity for semantic search
},
cancellationToken: cancellationToken
);
_logger.LogInformation("Collection {CollectionName} created successfully", _config.CollectionName);
}
_collectionInitialized = true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to initialize collection {CollectionName}", _config.CollectionName);
throw;
}
}
public async Task<List<SearchResult>> FindRelatedPostsAsync(
string slug,
string language,
int limit = 5,
CancellationToken cancellationToken = default)
{
if (_client == null || !_config.Enabled)
return new List<SearchResult>();
try
{
// Find the document by slug and language
var scrollResults = await _client.ScrollAsync(
collectionName: _config.CollectionName,
filter: new Filter
{
Must =
{
new Condition
{
Field = new FieldCondition
{
Key = "slug",
Match = new Match { Keyword = slug }
}
},
new Condition
{
Field = new FieldCondition
{
Key = "language",
Match = new Match { Keyword = language }
}
}
}
},
limit: 1,
cancellationToken: cancellationToken
);
var point = scrollResults.FirstOrDefault();
if (point == null)
{
_logger.LogWarning("Post {Slug} ({Language}) not found in vector store", slug, language);
return new List<SearchResult>();
}
// Use the document's vector to find similar posts
var searchResults = await _client.SearchAsync(
collectionName: _config.CollectionName,
vector: point.Vectors.Vector.Data.ToArray(),
limit: (ulong)(limit + 1), // +1 because the first result will be the post itself
scoreThreshold: _config.MinimumSimilarityScore,
cancellationToken: cancellationToken
);
// Filter out the original post and return top N similar posts
return searchResults
.Where(r => r.Payload["slug"].StringValue != slug || r.Payload["language"].StringValue != language)
.Take(limit)
.Select(result => new SearchResult
{
Slug = result.Payload["slug"].StringValue,
Title = result.Payload["title"].StringValue,
Language = result.Payload["language"].StringValue,
Categories = result.Payload.TryGetValue("categories", out var cats)
? cats.ListValue.Values.Select(v => v.StringValue).ToList()
: new List<string>(),
Score = result.Score,
PublishedDate = DateTime.Parse(result.Payload["published_date"].StringValue)
})
.ToList();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to find related posts for {Slug} ({Language})", slug, language);
return new List<SearchResult>();
}
}
// ... Additional methods for IndexDocument, Search, Delete, etc.
}
Vad är det som händer här:
- Cosinusavstånd: Vi använder cosinus likhet, vilket är perfekt för att jämföra normaliserade vektorer
- Lagring av metadata: Qdrant låter oss lagra extra data (payload) tillsammans med vektorer
- Filtrering: Vi kan filtrera resultat med metadata innan vi jämför vektorer
- Tröskelvärde för poäng: Endast returnera resultat över en viss likhet poäng
Steg 5: Orkestreringstjänsten
Denna service på hög nivå knyter allt samman:
using Microsoft.Extensions.Logging;
using Mostlylucid.SemanticSearch.Config;
using Mostlylucid.SemanticSearch.Models;
using System.Security.Cryptography;
using System.Text;
namespace Mostlylucid.SemanticSearch.Services;
public class SemanticSearchService : ISemanticSearchService
{
private readonly ILogger<SemanticSearchService> _logger;
private readonly SemanticSearchConfig _config;
private readonly IEmbeddingService _embeddingService;
private readonly IVectorStoreService _vectorStoreService;
public SemanticSearchService(
ILogger<SemanticSearchService> logger,
SemanticSearchConfig config,
IEmbeddingService embeddingService,
IVectorStoreService vectorStoreService)
{
_logger = logger;
_config = config;
_embeddingService = embeddingService;
_vectorStoreService = vectorStoreService;
}
public async Task IndexPostAsync(BlogPostDocument document, CancellationToken cancellationToken = default)
{
if (!_config.Enabled)
return;
try
{
// Prepare text for embedding: combine title and content
// We give more weight to the title by including it twice
var textToEmbed = $"{document.Title}. {document.Title}. {document.Content}";
// Truncate to reasonable length (embedding models have token limits)
const int maxLength = 2000;
if (textToEmbed.Length > maxLength)
{
textToEmbed = textToEmbed[..maxLength];
}
// Generate embedding
var embedding = await _embeddingService.GenerateEmbeddingAsync(textToEmbed, cancellationToken);
// Compute content hash if not provided
if (string.IsNullOrEmpty(document.ContentHash))
{
document.ContentHash = ComputeContentHash(document.Content);
}
// Store in vector database
await _vectorStoreService.IndexDocumentAsync(document, embedding, cancellationToken);
_logger.LogInformation("Indexed post {Slug} ({Language})", document.Slug, document.Language);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to index post {Slug} ({Language})", document.Slug, document.Language);
}
}
public async Task<List<SearchResult>> SearchAsync(
string query,
int limit = 10,
CancellationToken cancellationToken = default)
{
if (!_config.Enabled || string.IsNullOrWhiteSpace(query))
return new List<SearchResult>();
try
{
// Generate embedding for the search query
var queryEmbedding = await _embeddingService.GenerateEmbeddingAsync(query, cancellationToken);
// Search in vector store
var results = await _vectorStoreService.SearchAsync(
queryEmbedding,
Math.Min(limit, _conken);
_logger.LogDebug("Search for '{Query}' returned {Count} results", query, results.Count);
return results;
}
catch (Exception ex)
{
_logger.LogError(ex, "Search failed for query '{Query}'", query);
return new List<SearchResult>();
}
}
public async Task<List<SearchResult>> GetRelatedPostsAsync(
string slug,
string language,
int limit = 5,
CancellationToken cancellationToken = default)
{
if (!_config.Enabled)
return new List<SearchResult>();
try
{
var results = await _vectorStoreService.FindRelatedPostsAsync(
slug,
language,
Math.Min(limit, _config.RelatedPostsCount),
cancellationToken);
_logger.LogDebug("Found {Count} related posts for {Slug} ({Language})",
results.Count, slug, language);
return results;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get related posts for {Slug} ({Language})", slug, language);
return new List<SearchResult>();
}
}
private string ComputeContentHash(string content)
{
using var sha256 = SHA256.Create();
var bytes = Encoding.UTF8.GetBytes(content);
var hashBytes = sha256.ComputeHash(bytes);
return Convert.ToBase64String(hashBytes);
}
}
Steg 6: Beroende injektionsinställning
Registrera allt i DI-behållaren:
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Mostlylucid.SemanticSearch.Config;
using Mostlylucid.SemanticSearch.Services;
using Mostlylucid.Shared.Config;
namespace Mostlylucid.SemanticSearch.Extensions;
public static class ServiceCollectionExtensions
{
public static void AddSemanticSearch(
this IServiceCollection services,
IConfiguration configuration)
{
// Bind configuration using POCO pattern
services.ConfigurePOCO<SemanticSearchConfig>(
configuration.GetSection(SemanticSearchConfig.Section));
// Register services as singletons for efficiency
services.AddSingleton<IEmbeddingService, OnnxEmbeddingService>();
services.AddSingleton<IVectorStoreService, QdrantVectorStoreService>();
services.AddSingleton<ISemanticSearchService, SemanticSearchService>();
}
}
I din Program.cs:
using Mostlylucid.SemanticSearch.Extensions;
using Mostlylucid.SemanticSearch.Services;
// Add services
services.AddSemanticSearch(config);
// Initialize after building the app
using (var scope = app.Services.CreateScope())
{
var semanticSearch = scope.ServiceProvider.GetRequiredService<ISemanticSearchService>();
await semanticSearch.InitializeAsync();
}
Inrättande av infrastruktur
Docker komposera för Qdrant
Skapa en separat Docker-compose-fil för semantiska söktjänster:
version: '3.8'
services:
qdrant:
image: qdrant/qdrant:latest
container_name: mostlylucid-qdrant
restart: unless-stopped
ports:
- "6333:6333" # HTTP API
- "6334:6334" # gRPC API
volumes:
- qdrant_storage:/qdrant/storage
environment:
- QDRANT__SERVICE__HTTP_PORT=6333
- QDRANT__SERVICE__GRPC_PORT=6334
networks:
- mostlylucid_network
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:6333/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
volumes:
qdrant_storage:
driver: local
networks:
mostlylucid_network:
name: mostlylucidweb_app_network
external: true
Börja med:
docker-compose -f semantic-search-docker-compose.yml up -d
Ladda ner inbäddningsmodellen
Vi använder Alla-MiniLM-L6-v2 modell från Hugging Face's Dömande Transformatorer Denna modell är speciellt utbildad på semantiska likhetsuppgifter och producerar 384-dimensionella inbäddningar.
Automatisk nedladdning (rekommenderas)
Tjänsten hämtar automatiskt modellen från Hugging Face på första körningen om den inte finns:
// In OnnxEmbeddingService.cs
private const string ModelUrl = "https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2/resolve/main/onnx/model.onnx";
private const string VocabUrl = "https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2/resolve/main/vocab.txt";
public async Task EnsureInitializedAsync(CancellationToken cancellationToken = default)
{
if (_initialized || !_config.Enabled) return;
// Download model if not exists
if (!File.Exists(_config.EmbeddingModelPath))
{
_logger.LogInformation("Downloading ONNX embedding model to {Path}...", _config.EmbeddingModelPath);
await DownloadFileAsync(ModelUrl, _config.EmbeddingModelPath, cancellationToken);
}
// Download vocab if not exists
if (!File.Exists(_config.VocabPath))
{
_logger.LogInformation("Downloading vocabulary file to {Path}...", _config.VocabPath);
await DownloadFileAsync(VocabUrl, _config.VocabPath, cancellationToken);
}
// Initialize ONNX session...
}
Detta är särskilt användbart när du distribuerar med Docker - du kan kartlägga en volym för modellkatalogen:
volumes:
- ./mlmodels:/app/mlmodels # Model persists across container restarts
Manuell nedladdning
Alternativt kan du ladda ner manuellt:
chmod +x Mostlylucid.SemanticSearch/download-models.sh
./Mostlylucid.SemanticSearch/download-models.sh
Eller direkt från Hugging Face:
mkdir -p mlmodels
curl -L https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2/resolve/main/onnx/model.onnx -o mlmodels/all-MiniLM-L6-v2.onnx
curl -L https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2/resolve/main/vocab.txt -o mlmodels/vocab.txt
Den här nedladdningen:
all-MiniLM-L6-v2.onnx(~90MB) ONNX-exporterad inbäddningsmodellvocab.txt(~230KB) WordPiece tokenizer ordförråd
Prestandaöverväganden
Inbäddning av generation
- Prestanda hos processorn: ~50-100ms per inbäddning på en modern CPU
- Optimering: Vi använder en semafore för att förhindra samtidig ONNX inference
- Slagning: För bulkindexering, processtjänster i omgångar på 10-20
Vektorsökning
- Sökhastighet: < 10 ms för samlingar upp till 100 K vektorer
- Minnesanvändning: ~1KB per vektor (med metadata)
- Skalbarhet: Qdrant kan hantera miljontals vektorer på blygsam hårdvara
Cachingstrategi
Vi använder ASP.NET Core utgång cache:
[OutputCache(Duration = 7200, VaryByRouteValueNames = new[] {"slug", "language"})]
Denna cache relaterade inlägg i 2 timmar, avsevärt minska belastningen.
Vad vi har byggt
Vid denna punkt har du en komplett, fungerande semantisk sökfundament:
- ✅ ONNX-inbäddningar - CPU-vänliga, auto-downloads från Hugging Face
- ✅ Lagring av Qdrantvektor - Snabb likhetssökning med metadatafiltrering
- ✅ Relaterade tjänster - Hitta semantiskt liknande innehåll
- ✅ Söka API - Frågor om naturligt språk
- ✅ Innehållsindexering - Lagra blogginlägg som vektorer
Detta är den exakta inställningen som körs på den här bloggen - Ingen GPU, ingen extra kostnad.
Nästa: Semantisk sökning i verksamhet
Till Del 4b: Semantisk sökning i verksamhet, vi täcker:
- Typeaheadsökning - Hur sök-som-du-typ fungerar med Alpine.js
- Hybridsökning - Kombinera semantisk + PostgreSQL fulltext med Reciprocal Rank Fusion
- Söka API - Komplett API-dokumentation med filter
- Relaterade inlägg UI - DaisyUI komponenter med HTMX lat belastning
- Avancerade filter - Filtrering av språk- och datumintervall
Fortsätt till Häfte 4b för sökningen UI och hybrid sökning implementation.
Och sen Del 5: Hybridsökning och automatisk indexning omfattar produktionsintegrationsmönster.
Resurser
ONNX-dokumentation
- ONNX officiella webbplats - Den öppna standarden för ML-modeller
- ONNX körtid - Microsofts högpresterande inference motor
- ONNX Körtid .NET API - C# API- dokument
- ONNX Körtidsprestanda - Optimeringsguide
Qdrantdokumentation
- Självstämplade Vektordatabaser med Qdrant - Djupdyk i Qdrant koncept och C# klient
- Officiella dokument från Qdrant - Huvuddokumentationsnav
- Snabbstart för Qdrant - Komma igång
- Qdrant Vektorindexering - HNSW algoritm
- Qdrant .NET-klient - Officiellt .NET SDK
Inbäddade modeller
- Alla-MiniLM-L6-v2 - Modellen vi använder.
- Dömande Transformatorer - Semantiskt inbäddningsbibliotek
Fullständig kod
Alla koder finns på: github.com/scottgal/mestlylucidweb
Mostlylucid.SemanticSearch/- Kärnan semantiskt sökbibliotek