Derinlemesine yazılım eğitimleri için kanalımı takip edebilirsiniz...

.NET’te Vector Data’lar – Qdrant Vector Database’i Eşliğinde Semantic Search Uygulama

Merhaba,


Bu içeriğimizde akıllı uygulamalarda yaygın olarak kullanılan Semantic Search davranışını Qdrant Vector Database’i eşliğinde .NET’te pratik olarak ele alacak ve bu çerçevede bu olguların ne olduğuna dair bir incelemede bulunuyor olacağız.

Semantic Search Nedir?

Semantic Search (Anlamsal Arama), bir metni sadece kelimelere göre değil, anlamına göre arayan bir arama yöntemidir.

Misal olarak, kullanıcı tarafından araba fiyatları arandığında doküman olarak otomobil ücretleri‘nin getirilmesidir. Burada görüldüğü üzere bir kelime eşleşme durumu söz konusu olmasa da, bir anlamsal arama mevzu bahistir.

Nasıl Semantic Search Gerçekleştirir?

Semantic Search, metnin anlamına göre arama yapabilmek için çoğunlukla Vector Search davranışından istifade etmektedir.

Semantic Search bir konseptken, Vector Search ise bu konsepti gerçekleştiren bir tekniktir.

Bu kavramlara dair detaylı ve derinlemesine bilgi istiyorsanız eğer önceden klavyeye aldığım aşağıdaki içeriklere göz atmanızı kesinlikle tavsiye ederim:

  • Vector Search Nedir? Teorik Değerlendirmede Bulunalım…
    Bu içeriğimizde, vector search kavramına kökündeki vektörlerden başlayarak bir bakış sergilenmekte ve bir verinin vektörel dönüşümünden (embedding) tutun, benzerlik ölçümlerine kadar ilgili kavram tüm detaylarıyla ele alınmaktadır.
  • SQL Server 2025 – Vector Search
    Bu içeriğimizde ise özellikle semantic search süreci daha da detaylandırılmakta ve SQL Server 2025 özelinde deneyimlenmektedir.

Semantic Search için kelimeleri bağlamlarına göre anlamlandırmayı öğrenmiş olan özel bir embedding modeli kullanılmakta ve bu model ile aranan metinler vektörlere dönüştürülmektedir. Dolayısıyla bir metnin her search edilmesinde tekrar tekrar embedding’ini üretmek pek mantıklı olmayacaktır. Çünkü embedding oldukça maliyetli ve zaman alıcıdır. Dolayısıyla bizler bir metnin embedding’ini çıkardıysak bunu veritabanında saklamayı tercih etmekteyiz.

Böylece elde edilen embedding’ler, bir yandan veritabanında depolanırken bir yandan da indeksleme ve benzerlik araması gibi işlemlere tabi tutulabilmektedirler. Evet, bu tarz işlevsellikler performans optimizasyonuyla birlikte hazır bir şekilde bizlere sunulabilmektedir.

Qdrant Vector Database Nedir?

Qdrant, özellikle embedding edilmiş vektörel verileri saklamak ve bu veriler üzerinde benzerlik araması yapmak için tasarlanmış bir vector database’dir. Klasik veritabanından ziyade, -bu veriye en benzeyen diğer veriler hangileri?- sorusuna çok hızlı cevap verebilecek bir şekilde anlamsal bir çalışma sergilemektedir.

Normal database -eşleşeni bul- mantığında çalışırken, Qdrant ise -en yakın olanı tahmin et- mantığında çalışma sergilemektedir. Bundan kaynaklı, milyonlarca vektörün söz konusu olduğu durumlarda milisaniye seviyesinde en benzer sonuçlar elde edilebilir.

Point Kavramı
Qdrant database’de en küçük veri birimine Point denmektedir. Bu klasik veritabanındaki satırın (Row) karşılığıdır.

Neden ‘Point’ denmektedir?
Matematiksel olarak her vektör çok boyutlu uzayda bir noktaya (point) karşılık gelmektedir. Haliyle Qdrant açısından tüm veriler esasında bir noktadır. Qdrant, davranışsal olarak -bu noktaya en yakın noktalar hangileridir?- sorusunun cevabını aramaktadır.

Point, Qdrant içinde bir embedding vektörünü ve ona ait metadata’yı temsil eden temel veri birimidir.

Bir point yapısal olarak aşağıdaki üç parçadan meydana gelmektedir;

  • Id: Point’in benzersiz ID’sidir. Primary key gibi düşünülebilir. Upsert
  • Vector: Metnin sayısal halini (yani embedding edilmiş halini) ifade eder. Qdrant’ın anlamsal arama yaptığı alan tam da burasıdır.
  • Payload: Vektöre ait açıklayıcı bilgilerdir.

Qdrant NoSQL mi?
Evet, Qdrant teknik olarak NoSQL kategorisine girmektedir. Çünkü şema yoktur (schema-free), ilişkisel (relational) model yoktur ve esnek veri yapısına sahiptir.

Ama esasında saf bir NOSQL’dir de diyememekteyiz! Çünkü NOSQL’den öte özel bir amaçla geliştirilmiş Vector Database’dir. Çünkü MongoDB, Redis vs. gibi klasik NoSQL’ler, normal verileri saklamakta ve sorgu neticesinde eşleşenleri getirirlerken, Qdrant ise veriden ziyade verinin anlamı olan embedding halini saklamakta ve anlamsal sorgulama neticesinde benzerini getirmektedir!

Asp.NET Core’da Qdrant İle Vector Search Örneği

Şimdi Asp.NET Core’da bir vector search çalışması gerçekleştirelim. Tabi bunun için öncelikle uygulamaya Qdrant.Client kütüphanesini yükleyelim ve ardından aşağıdaki servisi inşa edelim.

    public class QdrantService1
    {
        readonly QdrantClient _qdrantClient;
        public QdrantService1()
            => _qdrantClient = new QdrantClient("localhost", 6334);

        public QdrantClient QdrantClient => _qdrantClient;

        public async Task CreateCollectionAsync(string collectionName)
        {
            var hasCollection = await _qdrantClient.CollectionExistsAsync(collectionName);

            if (!hasCollection)
                await _qdrantClient.CreateCollectionAsync(collectionName, new VectorParams
                {
                    Size = 1536, //Bu collection içerisinde her vector'ün kaç boyutlu olacağını belirtir. Örneğin, 1536 boyutlu bir vector kullanıyorsanız, bu değeri 1536 olarak ayarlamalısınız.
                    Distance = Distance.Cosine
                });
        }

        public async Task UpsertAsync(string collectionName, List<PointStruct> points)
            => await _qdrantClient.UpsertAsync(collectionName, points);

        public async Task<IReadOnlyList<ScoredPoint>> SearchAsync(string collectionName, ReadOnlyMemory<float> queryEmbedding, ulong? limit = 5, Filter? filter = null)
            => await _qdrantClient.SearchAsync(collectionName, queryEmbedding, limit: limit ?? 5, filter: filter);

        public async Task<ScrollResponse> GetVectorDatasFromCollectionAsync(string collectionName, uint? limit = 5, bool? vector = false)
        {
            var scrollResponse = await _qdrantClient.ScrollAsync(
                     collectionName: collectionName,
                     limit: limit ?? 5,
                     offset: null,
                     vectorsSelector: new WithVectorsSelector
                     {
                         Enable = vector ?? false
                     });

            return scrollResponse;
        }
    }

Bu çalışmada;

  • 16. satırdaki Size property’si, bu colleciton içindeki her vektörün kaç boyutlu olacağını ifade etmektedir. Bu değer, kullanılan embedding modeline göre belirlenmektedir. Misal olarak; OpenAI Embeddings API genelde 1536 boyutlu vektör üretmektedir.
  • 17. satırdaki Distance property’si ise benzerliğin hangi matematikle ölçüleceğini ifade etmektedir. Bu ne demek? diye sorarsanız, bu konulara girmek, hem içeriğin maksadını haddinden fazla saptıracağından hem de önceden klavyeye aldığım konulardaki bahislerin tekrarına sebebiyet vereceğinden, sizleri bu tarz ekstra (ama kritik) bilgileri toparlayabilmeniz için yukarıdaki satırlarda referans ettiğim önceki içeriklerime yönlendiriyorum.
  • 22. satırdaki UpsertAsync metodu ise Update + Insert kavramlarının kümülatif davranışına karşılık gelen bir işlevselliğe sahiptir. Yani eğer veri varsa güncelleyecektir, yoksa da ekleyecektir.
  • 29. satırda ise ScrollAsync metodu ile verdiğimiz colection’dan belirtilen limit değeri kadar point elde edilmektedir. Bu metodun offset parametresi bir cursor görevi görmektedir. Yani milyonlarca verinin söz konusu olduğu senaryolarda tüm verileri tek seferde elde etmeye çalışmaktansa, offset ile performanslı bir şekilde parça parça edinebilir ve bir bookmark mantığında kullanabiliriz.
  • 33. satırdaki WithVectorsSelector ise embedding (vector) verisinin döndürülüp döndürülmeyeceğinin kararını yapılandırmaktadır. Malum, Qdrant’taki bir kayıt id, payload ve vector olmak üzere üç parçadan oluşmaktadır. vectorsSelector parametresi ile ScrollAsync metodunda vektörlerin getirilmesi irademize bırakılmaktadır. Bunun nedeni, çok büyük veri setinin söz konusu olduğu durumlarda her biri 1536 boyutuyla KB seviyesine çıkan embedding verilerinin sonuç datasını şişireceği gerçeğidir. Bundan kaynaklı ihtiyaca dönük yapılandırılabilir bir şekilde bu parametre bizlere sunulmaktadır.

Benzer çalışmayı aşağıdaki gibi bir servis oluşturarak da gerçekleştirebiliriz:

    public class QdrantService2<T> where T : class
    {
        readonly QdrantClient _qdrantClient;
        readonly QdrantVectorStore _qdrantVectorStore;
        public QdrantService2(EmbeddingService embeddingService, IConfiguration configuration)
        {
            IEmbeddingGenerator<string, Embedding<float>> embeddingGenerator = embeddingService.EmbeddingGenerator;

            _qdrantClient = new QdrantClient(configuration["Qdrant:Host"], int.Parse(configuration["Qdrant:Port"]));
            _qdrantVectorStore = new QdrantVectorStore(
                qdrantClient: _qdrantClient,
                ownsClient: true,
                options: new QdrantVectorStoreOptions
                {
                    EmbeddingGenerator = embeddingGenerator
                });
        }

        public QdrantClient QdrantClient => _qdrantClient;
        public QdrantVectorStore QdrantVectorStore => _qdrantVectorStore;

        public async Task<QdrantCollection<Guid, T>> GetCollectionAsync(string collectionName)
        {
            var collection = _qdrantVectorStore.GetCollection<Guid, T>(collectionName);
            await collection.EnsureCollectionExistsAsync();
            return collection;
        }

        public async Task UpsertAsync(string collectionName, List<T> record)
        {
            var collection = await GetCollectionAsync(collectionName);
            await collection.UpsertAsync(record);
        }

        public async Task<IAsyncEnumerable<VectorSearchResult<T>>> SearchAsync(string collectionName, string searchValue, int? top = 5)
        {
            var collection = await GetCollectionAsync(collectionName);
            return collection.SearchAsync(searchValue, top: top ?? 5);
        }

        public async Task<ScrollResponse> GetVectorDatasFromCollectionAsync(string collectionName, uint? limit = 5)
        {
            var scrollResponse = await _qdrantClient.ScrollAsync(
                    collectionName: collectionName,
                    limit: limit ?? 5,
                    offset: null,
                    vectorsSelector: new WithVectorsSelector
                    {
                        Enable = true
                    });

            return scrollResponse;
        }
    }

Bu çalışmada Semantic Kernel abstraction’ınını uygulayan, high-level bir yaklaşıma sahiptir. Dikkat ederseniz bir önceki yaklaşımımızda SearchAsync metodunda embedding edilmiş veri Qdrant’a gönderilip aranıyorken, bu yaklaşımda ise metinsel verinin embedding edilme sorumluluğu QdrantVectorStore yapılanması tarafından üstlenilmektedir. Tabi bunu uygulayabilmek için
Microsoft.SemanticKernel.Connectors.Qdrant kütüphanesinin kullanılması gerekmektedir.

Ayrıca ilk yaklaşımımızda veri türü olarak PointStruct kullanılıyorken, ikincisinde ise veri modelini kendimiz tasarlayıp, oluşturmak mecburiyetindeyiz. Misal olarak;

    public class VectorModel
    {
        [VectorStoreKey]
        public Guid Id { get; set; }
        [VectorStoreData]
        public string Text { get; set; }

        [VectorStoreData(IsIndexed = true)]
        public string Category { get; set; }

        [VectorStoreVector(1536)]
        public float[] Vectors { get; set; }
    }

şeklinde bir model tasarlayabiliriz. Burada VectorStoreKey her bir vektörün benzersiz kimlik değerine karşılık gelirken, VectorStoreData metadata’lara, VectorStoreVector ise embedding edilmiş veriye karşılık gelmektedir.

Semantic Search

Her iki yaklaşımında SearchAsync metodu semantic search davranışı sergileyecektir.

QdrantService1 ile semantic search;

        var embedding = await embeddingService.GenerateVectorAsync(text);
        var scoredPoint = await qdrantService1.SearchAsync("exampleCollection", embedding);
        var results = scoredPoint
            .SelectMany(point => point.Payload,
                       (point, payload) => $"{payload.Key}: {payload.Value.StringValue}");

QdrantService2 ile semantic search;

        var scoredPoint = await qdrantService2.SearchAsync("exampleCollection", text);
        var results = scoredPoint
            .Where(point => point.Record.Category is not null && point.Record.Text is not null)
            .Select(point => $"Category: {point.Record.Category}\nText: {point.Record.Text}");

Hatta ikinci yaklaşımda semantic search neticesinde gelen data IAsyncEnumerable<VectorSearchResult<T>> türünden dönüş değerine sahip olduğu için istendiği taktirde aşağıdaki gibi stream edilebilmektedir;

    await foreach (var result in scoredPoint)
    {
        Console.WriteLine($"Score: {result.Score}, Text: {result.Record.Text}");
    }
Filtering results

Semantic search sürecinde metadata’lar üzerinden aşağıdaki yöntemlerle filtrelemeler gerçekleştirilebilir:

QdrantService1 ile filtering’de misal olarak; yalnızca ‘alışveriş & ürün arama’ kategorisine özel bir filtreleme yapmak istersek eğer aşağıdaki gibi bir çalışma sergilenebilir.

        Filter filter = new Filter
        {
            Must =
            {
                new Condition
                {
                    Field = new FieldCondition
                    {
                        Key = "Category",
                        Match = new Match
                        {
                            Keyword = "alışveriş & ürün arama"
                        }
                    }
                }
            }
        };

        var scoredPoint = await qdrantService1.SearchAsync("exampleCollection", embedding, filter: filter);

Burada Must property’si içerisine verdiğimiz her bir Condition AND mantığında davranış sergilemektedir. Eğer ki OR mantığında çalışmak istersek Should property’sine Condition verilmelidir.

Aynı filtreleme mantığını QdrantService2 ile örneklendirirsek eğer;

        var vectorSearchOptions = new VectorSearchOptions<VectorModel>()
        {
            Filter = vectorModel => vectorModel.Category == "alışveriş & ürün arama"
        };

        var scoredPoint = await qdrantService2.SearchAsync("exampleCollection", text, vectorSearchOptions: vectorSearchOptions);

şeklinde olacaktır… Evet, görüldüğü üzere ikinci yaklaşımda filtreleme süreci standart LINQ ifadeleri eşliğinde gerçekleştirilebilmektedir…

Filter, payload üzerinde çalışmaktadır! Vector ile hiçbir alakası söz konusu değildir!

Peki hangi yaklaşımı tercih etmeliyiz?
İlk yaklaşımda tüm kontroller elimizde olduğu için performans odaklı optimize çalışma sergileme ihtimalimiz oldukça yüksektir. Ancak bu da çok fazla boilerplate durum meydana getirebilmektedir. Ayrıca AI pipeline entegrasyon süreci de ister istemez manuel olacağı için zorlaşabilmektedir.

İkinci yaklaşımda ise embedding sürecinin otomatik olması oldukça hızlı geliştirme sağlıyor olsa da debugging’i zorlaştırmakta ve bulk işlemlerde sınırlılık tanımaktadır.

Haliyle şu yaklaşımı tercih etmeliyizden ziyade en doğru kombinasyonu ortaya koyabilmeli ve ihtiyaç durumuna karşın hybrid yapılanmanın getirisi olan esneklikten istifade etmeliyiz.


Nihai olarak;
Semantic Search davranışını Qdrant ile .NET’te uygulamak, teknik olarak karmaşık görünse de aslında tüm mesele doğru abstraciton seviyesini seçmekten ibarettir 🙂 İster low-level kontrol olsun isterse de high-level, farketmeksizin, mühim olan son kullanıcının “araba fiyatları” talebine karşın “otomobil ücretleri”ni sunabilmemizdir. Biz, .NET geliştiricileri açısından bu yetkinliği kazanmak, geliştirdiğimiz uygulamaları geleceğe taşımak adına en etkili ve kritik yollardan birisidir.

İlgilenenlerin faydalanması dileğiyle…
Sonraki yazılarımda görüşmek üzere…
İyi çalışmalar…

Örnek çalışmaya aşağıdaki GitHub reposundan erişebilirsiniz.
https://github.com/gncyyldz/SemanticSearch.With.Qdrant.Vector.Database.Example

Bu repository, ilgili konuya dair örnek çalışmanın kaynak kodlarını ve mimari yapısını içermektedir. Detaylar için GitHub üzerinden incelemede bulunabilirsiniz.


GitHub’da Görüntüle →

Bunlar da hoşunuza gidebilir...

Bir yanıt yazın

E-posta adresiniz yayınlanmayacak. Gerekli alanlar * ile işaretlenmişlerdir