This semantic kernel semantic text memory tutorial walks you through building a working memory system from scratch. You’ll set up the kernel, configure a vector store, store text with embeddings, and query it semantically — all with runnable code you can adapt to production workloads.
Prerequisites
- .NET 8 SDK or later
- An OpenAI API key (or compatible endpoint) for embeddings
- Basic familiarity with C# and dependency injection
Create a new console project:
dotnet new console -n SkMemoryDemo
cd SkMemoryDemo
Add the required packages:
dotnet add package Microsoft.SemanticKernel
dotnet add package Microsoft.SemanticKernel.Memory
dotnet add package Microsoft.SemanticKernel.Connectors.OpenAI
dotnet add package Microsoft.SemanticKernel.Connectors.Qdrant
dotnet add package Microsoft.Extensions.DependencyInjection
dotnet add package Microsoft.Extensions.Logging.Console
Configure the kernel and memory
Semantic text memory in Semantic Kernel separates concerns: the kernel handles orchestration, while ISemanticTextMemory handles embedding generation, storage, and retrieval. Wire them together in Program.cs:
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.Connectors.OpenAI;
using Microsoft.SemanticKernel.Memory;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddLogging(c => c.AddConsole().SetMinimumLevel(LogLevel.Information));
// OpenAI embedding service — text-embedding-3-small is a good default
builder.Services.AddOpenAITextEmbeddingGeneration(
modelId: "text-embedding-3-small",
apiKey: Environment.GetEnvironmentVariable("OPENAI_API_KEY") ?? "your-key-here"
);
// In-memory vector store for development; swap for Qdrant/Redis in prod
builder.Services.AddSingleton<IVectorStore, VolatileVectorStore>();
// Semantic text memory ties embedding generation to the vector store
builder.Services.AddSemanticTextMemory();
var app = builder.Build();
// Quick sanity check
var memory = app.Services.GetRequiredService<ISemanticTextMemory>();
var kernel = app.Services.GetRequiredService<Kernel>();
Console.WriteLine("Kernel and memory ready.");
Run it:
export OPENAI_API_KEY=sk-...
dotnet run
Expected output:
Kernel and memory ready.
Store your first memories
ISemanticTextMemory.SaveInformationAsync takes a collection name, text, an optional ID, and optional metadata. The collection acts like a namespace — use it to separate domains (support tickets, documentation, user profiles).
using Microsoft.SemanticKernel.Memory;
var memory = app.Services.GetRequiredService<ISemanticTextMemory>();
const string Collection = "product-docs";
await memory.SaveInformationAsync(Collection,
id: "doc-1",
text: "The n4n.ai gateway routes requests across 240+ models with automatic fallback when a provider is rate-limited.",
metadata: "source:architecture-doc;version:2024-03");
await memory.SaveInformationAsync(Collection,
id: "doc-2",
text: "Per-token usage metering is exposed via response headers so clients can track cost in real time.",
metadata: "source:billing-doc;version:2024-03");
await memory.SaveInformationAsync(Collection,
id: "doc-3",
text: "Cache-control hints from upstream providers are forwarded unchanged to the client.",
metadata: "source:performance-doc;version:2024-03");
Console.WriteLine("Saved 3 documents.");
Run again — you should see “Saved 3 documents.” The VolatileVectorStore keeps data in process memory, so it disappears on restart. That’s fine for this tutorial; we’ll swap it shortly.
Search semantically
SearchAsync returns MemoryQueryResult objects with score, metadata, and the original text. The score is cosine similarity (0–1, higher is closer).
var results = memory.SearchAsync(Collection, "how does fallback work?", limit: 3, minRelevanceScore: 0.6);
await foreach (var result in results)
{
Console.WriteLine($"Score: {result.Relevance:F3} | ID: {result.Metadata.Id}");
Console.WriteLine($"Text: {result.Metadata.Text}");
Console.WriteLine($"Meta: {result.Metadata.AdditionalMetadata}");
Console.WriteLine();
}
Expected output (scores will vary slightly):
Score: 0.842 | ID: doc-1
Text: The n4n.ai gateway routes requests across 240+ models with automatic fallback when a provider is rate-limited.
Meta: source:architecture-doc;version:2024-03
Score: 0.718 | ID: doc-3
Text: Cache-control hints from upstream providers are forwarded unchanged to the client.
Meta: source:performance-doc;version:2024-03
The second result appears because “provider” and “fallback” share semantic space — this is the point of semantic memory.
Add metadata filtering
Metadata is stored as a flat string in the volatile store, but production vector stores support structured filters. For now, parse it manually:
var results = memory.SearchAsync(Collection, "token usage", limit: 5, minRelevanceScore: 0.5);
await foreach (var result in results)
{
var meta = result.Metadata.AdditionalMetadata ?? "";
if (meta.Contains("billing"))
{
Console.WriteLine($"[BILLING] Score: {result.Relevance:F3} | {result.Metadata.Text}");
}
}
Output:
[BILLING] Score: 0.891 | Per-token usage metering is exposed via response headers so clients can track cost in real time.
Swap to a persistent vector store (Qdrant)
VolatileVectorStore is useless beyond demos. Qdrant runs locally via Docker and supports filtering, payloads, and horizontal scaling.
Start Qdrant:
docker run -d -p 6333:6333 -p 6334:6334 qdrant/qdrant
Update Program.cs to use the Qdrant connector:
using Microsoft.SemanticKernel.Connectors.Qdrant;
// Replace the VolatileVectorStore registration
builder.Services.AddSingleton<IVectorStore>(sp =>
new QdrantVectorStore(new QdrantClient("http://localhost:6333")));
The rest of your code — SaveInformationAsync, SearchAsync — stays identical. That’s the value of the IVectorStore abstraction.
Run the save step again, then restart the app and run only the search step. Your data persists.
Build a practical RAG helper
Wrap the memory in a reusable service your application code can call:
public sealed class ProductKnowledgeBase
{
private readonly ISemanticTextMemory _memory;
private const string Collection = "product-docs";
public ProductKnowledgeBase(ISemanticTextMemory memory) => _memory = memory;
public async Task IngestAsync(string id, string text, Dictionary<string, string>? metadata = null)
{
var metaString = metadata != null
? string.Join(";", metadata.Select(kv => $"{kv.Key}:{kv.Value}"))
: null;
await _memory.SaveInformationAsync(Collection, id, text, metadata: metaString);
}
public async Task<IReadOnlyList<MemoryQueryResult>> QueryAsync(string question, int limit = 5, double minScore = 0.6)
{
var results = new List<MemoryQueryResult>();
await foreach (var result in _memory.SearchAsync(Collection, question, limit, minScore))
{
results.Add(result);
}
return results;
}
public async Task<string> GetContextForPromptAsync(string question, int maxTokens = 1500)
{
var results = await QueryAsync(question, limit: 10, minScore: 0.55);
var builder = new StringBuilder();
var tokenCount = 0;
foreach (var r in results)
{
var snippet = r.Metadata.Text;
var estTokens = snippet.Length / 4; // rough heuristic
if (tokenCount + estTokens > maxTokens) break;
builder.AppendLine($"[Source: {r.Metadata.Id}] {snippet}");
tokenCount += estTokens;
}
return builder.ToString();
}
}
Register it:
builder.Services.AddScoped<ProductKnowledgeBase>();
Use it from a minimal API endpoint or console command:
var kb = app.Services.GetRequiredService<ProductKnowledgeBase>();
// Ingest
await kb.IngestAsync("doc-4",
"Client routing directives in the request header override default model selection.",
new Dictionary<string, string> { ["source"] = "routing-doc", ["team"] = "platform" });
// Retrieve context for an LLM prompt
var context = await kb.GetContextForPromptAsync("How do I force a specific model?");
Console.WriteLine(context);
Output:
[Source: doc-4] Client routing directives in the request header override default model selection.
Feed that context into your chat completion call — now you have grounded generation.
Handle embedding dimensions and model changes
If you switch embedding models, dimension mismatches break search. Guard against this at startup:
var embeddingGen = app.Services.GetRequiredService<ITextEmbeddingGenerationService>();
var testEmbedding = await embeddingGen.GenerateEmbeddingAsync("dimension check");
Console.WriteLine($"Embedding dimension: {testEmbedding.Length}");
var store = app.Services.GetRequiredService<IVectorStore>();
var collection = store.GetCollection<float>("product-docs");
var info = await collection.GetCollectionInfoAsync();
Console.WriteLine($"Store dimension: {info?.VectorSize ?? "unknown"}");
If they differ, delete and recreate the collection. In Qdrant, collections are immutable — you must drop and re-create:
await store.DeleteCollectionAsync("product-docs");
await store.CreateCollectionAsync("product-docs", new VectorStoreCollectionDefinition
{
VectorSize = testEmbedding.Length,
DistanceFunction = DistanceFunction.CosineSimilarity
});
Batch ingestion for larger datasets
Single SaveInformationAsync calls add network round-trips. Batch them:
public async Task BulkIngestAsync(IEnumerable<(string Id, string Text, Dictionary<string, string> Meta)> items)
{
var batch = new List<MemoryRecord>();
foreach (var (id, text, meta) in items)
{
var embedding = await _embeddingGen.GenerateEmbeddingAsync(text);
var record = new MemoryRecord
{
Id = id,
Text = text,
Embedding = embedding,
Metadata = string.Join(";", meta.Select(kv => $"{kv.Key}:{kv.Value}"))
};
batch.Add(record);
}
// QdrantVectorStoreCollection supports upsert batch
var collection = _store.GetCollection<MemoryRecord>("product-docs");
await collection.UpsertBatchAsync(batch);
}
This reduces 1000 documents from ~1000 HTTP calls to ~10.
Common pitfalls
Forgetting to await the async enumerable. SearchAsync returns IAsyncEnumerable<MemoryQueryResult>. You must await foreach — a plain foreach compiles but never executes the query.
Using the wrong collection name. Collection names are case-sensitive in Qdrant. Define a constant.
Ignoring minRelevanceScore. Default is 0.0, which returns noise. Start at 0.6 and tune per domain.
Embedding model drift. If you re-embed existing data with a new model, scores become meaningless. Version your collections: product-docs-v2, product-docs-v3.
What’s next
- Add hybrid search (BM25 + vector) via Qdrant’s sparse vector support
- Implement incremental updates: hash content, skip unchanged records
- Add observability: log query latency, result count, and top scores
- Evaluate retrieval quality with a labeled test set before trusting it in production
The pattern stays the same: ISemanticTextMemory for the abstraction, IVectorStore for the backend, and your domain logic on top. Swap stores, swap models, keep your application code stable.