using System.Security.Cryptography; using System.Text; using System.Text.RegularExpressions; using Qdrant.Client; using Qdrant.Client.Grpc; using static Qdrant.Client.Grpc.Conditions; using Chunk = (string Url, string Anchor, int ChunkNum, string Text, string SectionUrl, string ContentHash, string PointId); public class Snippet { public static async Task Run() { // @block-start client-connection // Replace the host and API key with your own from https://cloud.qdrant.io var client = new QdrantClient( host: "xyz-example.qdrant.io", https: true, apiKey: "" ); // @block-end client-connection // @hide-start // data and text normalization are not the lesson of this tutorial: // the full CHUNKS list and Normalize() live in the tutorial notebook var CHUNKS = new List { ( Url: "https://qdrant.tech/documentation/tutorials-operations/secure-qdrant/", Anchor: "prerequisites", ChunkNum: 0, Text: "Prerequisites - Docker and Docker Compose installed - `curl` available in your terminal ...", SectionUrl: "", ContentHash: "", PointId: "" ), ( Url: "https://qdrant.tech/documentation/tutorials-operations/secure-qdrant/", Anchor: "step-3-enable-an-admin-api-key", ChunkNum: 0, Text: "Step 3: Enable an Admin API Key Without enabling authentication, anyone with network access ...", SectionUrl: "", ContentHash: "", PointId: "" ), }; string Normalize(string text) => Regex.Replace(text, @"\s+", " ").Trim(); // @hide-end // @block-start create-collection var MODEL = "sentence-transformers/all-MiniLM-L6-v2"; var PIPELINE = "docs-prep-pipeline-v1"; var COLLECTION = "docs-sync-tutorial"; await client.CreateCollectionAsync( collectionName: COLLECTION, vectorsConfig: new VectorParams { Size = 384, // all-MiniLM-L6-v2 output dimension Distance = Distance.Cosine }, metadata: new() { ["embedding_model"] = MODEL, ["pipeline_version"] = PIPELINE } ); // @block-end create-collection // @block-start check-gate async Task CheckGate() { // compare this pipeline's constants against what the collection records about itself var meta = (await client.GetCollectionInfoAsync(COLLECTION)).Config.Metadata; var model = meta.GetValueOrDefault("embedding_model")?.StringValue; var pipeline = meta.GetValueOrDefault("pipeline_version")?.StringValue; if (model != MODEL || pipeline != PIPELINE) throw new InvalidOperationException( $"collection was built by {model}/{pipeline}: full re-embed into a fresh collection required"); } // @block-end check-gate // @block-start identity-and-fingerprint string ContentHash(string text) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(text))).ToLowerInvariant(); // Qdrant accepts any well-formed UUID as a point ID: // a Guid built from the first 16 bytes of the address hash, so the same address always yields the same ID string PointIdFor(string url, string anchor, int num) => new Guid(SHA256.HashData(Encoding.UTF8.GetBytes($"{url}#{anchor}::{num}")).AsSpan(0, 16)).ToString(); // Derive both values (and the section address) for every raw chunk. List PrepareChunksForSync(List chunks) { var prepared = new List(); foreach (var c in chunks) { var text = Normalize(c.Text); prepared.Add(c with { Text = text, SectionUrl = c.Anchor != "" ? $"{c.Url}#{c.Anchor}" : c.Url, ContentHash = ContentHash(text), PointId = PointIdFor(c.Url, c.Anchor, c.ChunkNum), }); } return prepared; } // @block-end identity-and-fingerprint // @block-start payload Dictionary Payload(Chunk chunk, string? lastUpdated = null) => new() { ["url"] = chunk.Url, ["anchor"] = chunk.Anchor, ["chunk_num"] = chunk.ChunkNum, ["section_url"] = chunk.SectionUrl, ["text"] = chunk.Text, ["content_hash"] = chunk.ContentHash, ["last_updated"] = lastUpdated ?? DateTimeOffset.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssK"), }; // @block-end payload // @block-start payload-indexes foreach (var field in new[] { "content_hash", "url", "section_url" }) await client.CreatePayloadIndexAsync(COLLECTION, field, PayloadSchemaType.Keyword); // @block-end payload-indexes // @block-start populate await client.UpsertAsync( collectionName: COLLECTION, points: PrepareChunksForSync(CHUNKS).Select(c => new PointStruct { Id = new PointId { Uuid = c.PointId }, Vectors = new Document { Text = c.Text, Model = MODEL }, Payload = { Payload(c) }, }).ToList(), wait: true ); // @block-end populate // @block-start search var QUERY = "Where exactly to set `QDRANT__SERVICE__API_KEY` variable to enable authentication for a self-hosted Qdrant?"; await client.QueryAsync( collectionName: COLLECTION, query: new Document { Text = QUERY, Model = MODEL }, limit: 3, payloadSelector: new[] { "section_url", "text" } ); // @block-end search // @hide-start // the simulated month of edits (LATEST_CHUNKS) is spelled out in the tutorial and the notebook var LATEST_CHUNKS = PrepareChunksForSync(CHUNKS); // @hide-end // @block-start split-by-state // Compare the incoming chunk list to the collection: who is unchanged, changed, or unknown. async Task<(Dictionary incomingIds, List unchanged, List contentChanged, List unknownIds)> SplitByState(List latestChunks) { var incoming = latestChunks.ToDictionary(c => c.PointId); var stored = new Dictionary(); var points = await client.RetrieveAsync( COLLECTION, ids: incoming.Keys.Select(pid => new PointId { Uuid = pid }).ToList(), payloadSelector: new[] { "content_hash" }, vectorSelector: false ); foreach (var p in points) stored[p.Id.Uuid] = p.Payload["content_hash"].StringValue; var unchanged = new List(); var contentChanged = new List(); var unknownIds = new List(); foreach (var (pid, c) in incoming) { if (stored.TryGetValue(pid, out var hash) && hash == c.ContentHash) unchanged.Add(c); else if (stored.ContainsKey(pid)) contentChanged.Add(c); else unknownIds.Add(c); } return (incoming, unchanged, contentChanged, unknownIds); } var splitState = await SplitByState(LATEST_CHUNKS); // @block-end split-by-state // @block-start re-embed-changed async Task ReEmbedChanged(List contentChanged) { if (contentChanged.Count == 0) return; await client.UpsertAsync( collectionName: COLLECTION, points: contentChanged.Select(c => new PointStruct { Id = new PointId { Uuid = c.PointId }, Vectors = new Document { Text = c.Text, Model = MODEL }, Payload = { Payload(c) }, }).ToList(), wait: true ); } // @block-end re-embed-changed // @block-start reuse-or-add // Reuse an existing embedding when the same text is already stored; embed only what is new. async Task<(int reused, int added)> ReuseOrAdd(List unknownIds) { int reused = 0, added = 0; foreach (var c in unknownIds) { var sameText = new Filter { Must = { MatchKeyword("content_hash", c.ContentHash) } }; var hits = (await client.ScrollAsync( COLLECTION, filter: sameText, limit: 1, payloadSelector: new[] { "last_updated" }, vectorsSelector: true )).Result; PointStruct point; if (hits.Count > 0) // same text, new address: copy the vector, keep its last_updated { point = new PointStruct { Id = new PointId { Uuid = c.PointId }, Vectors = hits[0].Vectors.Vector.GetDenseVector()!.Data.ToArray(), Payload = { Payload(c, hits[0].Payload["last_updated"].StringValue) }, }; reused++; } else // genuinely new content: embed and insert { point = new PointStruct { Id = new PointId { Uuid = c.PointId }, Vectors = new Document { Text = c.Text, Model = MODEL }, Payload = { Payload(c) }, }; added++; } await client.UpsertAsync(COLLECTION, points: new List { point }, wait: true); } return (reused, added); } // @block-end reuse-or-add // @block-start delete-gone // Remove every point the current crawl no longer contains. Returns how many. async Task DeleteGone(Dictionary incomingIds) { if (incomingIds.Count == 0) throw new ArgumentException("Refusing to delete from an empty source snapshot."); var stale = new Filter { MustNot = { HasId(incomingIds.Keys.Select(Guid.Parse).ToList()) } }; var toDelete = await client.CountAsync(COLLECTION, filter: stale); // potential check against a threshold to avoid accidental mass deletion could be added here await client.DeleteAsync(COLLECTION, filter: stale, wait: true); return toDelete; } // @block-end delete-gone // @block-start sync async Task> Sync(List latestChunks) { await CheckGate(); // refuse to mix embedding models or pipeline versions var chunks = PrepareChunksForSync(latestChunks); var (incomingIds, unchanged, contentChanged, unknownIds) = await SplitByState(chunks); await ReEmbedChanged(contentChanged); var (reused, added) = await ReuseOrAdd(unknownIds); var deleted = await DeleteGone(incomingIds); return new Dictionary { ["unchanged"] = unchanged.Count, ["re-embedded"] = contentChanged.Count, ["reused_embedding"] = reused, ["added"] = added, ["deleted"] = (long)deleted, }; } // @block-end sync // @block-start run-sync var run = await Sync(LATEST_CHUNKS); foreach (var (op, count) in run) Console.WriteLine($"{op}: {count}"); // @block-end run-sync } }