mirror of
https://github.com/qdrant/landing_page.git
synced 2026-10-09 12:58:31 +02:00
309 lines
9.6 KiB
C#
309 lines
9.6 KiB
C#
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: "<your-api-key>"
|
|
);
|
|
// @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<Chunk>
|
|
{
|
|
(
|
|
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<Chunk> PrepareChunksForSync(List<Chunk> chunks)
|
|
{
|
|
var prepared = new List<Chunk>();
|
|
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<string, Value> 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<string, Chunk> incomingIds, List<Chunk> unchanged, List<Chunk> contentChanged, List<Chunk> unknownIds)>
|
|
SplitByState(List<Chunk> latestChunks)
|
|
{
|
|
var incoming = latestChunks.ToDictionary(c => c.PointId);
|
|
|
|
var stored = new Dictionary<string, string>();
|
|
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<Chunk>();
|
|
var contentChanged = new List<Chunk>();
|
|
var unknownIds = new List<Chunk>();
|
|
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<Chunk> 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<Chunk> 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<PointStruct> { 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<ulong> DeleteGone(Dictionary<string, Chunk> 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<Dictionary<string, long>> Sync(List<Chunk> 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<string, long>
|
|
{
|
|
["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
|
|
}
|
|
|
|
}
|