LLM'lerde Embedding ve Benzerlik: Metni Anlamına Göre Aramak
Embedding nedir, cosine similarity nasıl hesaplanır? nomic-embed-text ile 768 boyutlu vector'ler üretiyor, C# ve TypeScript'te elle cosine similarity yazıyor, bellekte küçük bir semantic search kurup anahtar kelime aramasıyla karşılaştırıyoruz. Olumsuz cümle tuzağı ve Türkçe sorgularla sınırlar gerçek puanlarla.
Sürüm notu: Bu yazıdaki örnekler Ollama 0.34 ile yerel embedding modelinde (nomic-embed-text) denenmiştir; .NET 10 (Microsoft.Extensions.AI 10.11.0, OllamaSharp 5.5.2) ve Node 22 kullanılmıştır.
Önce Tahmin Et
Üç cümle düşün: "Your order has shipped.", "Your package is on its way." ve "Your order has not shipped." İlk cümleye anlamca hangisi daha yakın? Bir embedding modeline sorsak hangisine daha yüksek benzerlik puanı verir?
İnsan için cevap belli: ikinci cümle aynı şeyi başka kelimelerle söylüyor, üçüncüsü tam tersini. Modelin cevabını yazının ortasında gerçek puanlarla göreceğiz; sonuç, embedding'in ne olduğunu ve ne olmadığını anlamak için iyi bir başlangıç.
Embedding Nedir?
Embedding, bir metni sabit uzunlukta bir sayı dizisine, yani bir vector'e çeviren modelin çıktısıdır. Sohbet modelleri metin üretir; embedding modelleri metin üretmez, metnin "anlam koordinatlarını" verir. Benzer anlamdaki metinler bu uzayda birbirine yakın noktalara düşer.
Harita benzetmesi işe yarar. Bir şehri enlem ve boylamla iki sayıyla anlatırsın, birbirine yakın şehirlerin sayıları da yakın olur. Embedding aynı fikri iki yerine yüzlerce boyutla yapar. Boyutların tek tek bir anlamı yoktur; "uzay" ya da "yemek" diye etiketlenmiş bir boyut aramazsın. Anlamlı olan, iki vector arasındaki açıdır.
Bu yazıda Ollama'daki nomic-embed-text modelini kullanıyoruz. ollama show çıktısına göre 137M parametreli, F16 ağırlıklı küçük bir model; sohbet modellerinin yanında çok hafif. Yaptığı tek iş vector üretmek: capabilities listesinde yalnızca embedding var.
İlk Vektör
ollama paketinin embed method'u bir string ya da string array'i alır ve her biri için bir vector döner:
import ollama from 'ollama';
const response = await ollama.embed({
model: 'nomic-embed-text',
// nomic-embed-text bir görev öneki bekler: search_query / search_document
input: ['search_query: a book about surviving on another planet'],
});
const vector = response.embeddings[0];
// Vektörün uzunluğu (L2 norm)
const norm = Math.sqrt(vector.reduce((sum, x) => sum + x * x, 0));
console.log('dimensions:', vector.length);
console.log('first 5:', vector.slice(0, 5).map((x) => x.toFixed(4)).join(', '));
console.log('norm:', norm.toFixed(4));
console.log('prompt tokens:', response.prompt_eval_count);
$ npx tsx embed-basics.ts
dimensions: 768
first 5: 0.0021, 0.0249, -0.2053, -0.0557, 0.0372
norm: 1.0000
prompt tokens: 13
Dört satırın her biri bir şey anlatıyor. Vector 768 boyutlu; sorgunun kısa ya da uzun olması bunu değiştirmez. İlk beş sayıya bakıp bir anlam çıkaramazsın, beklenen de bu. Norm 1: Ollama'nın döndürdüğü vector'ler birim uzunlukta geliyor, bunun pratik faydasını birazdan göreceğiz. Son satır, embedding'in de token harcadığını hatırlatıyor; üretim yok ama okuma var.
Metnin başındaki search_query: bir süs değil. nomic-embed-text'in model kartı, metnin bir görev önekiyle (task instruction prefix) başlaması gerektiğini söylüyor: aranacak dokümanlar için search_document: , arama sorgusu için search_query: , kümeleme için clustering: , sınıflandırma için classification: . Model aynı metni bu öneke göre biraz farklı yerleştirir.
Cosine Similarity'yi Elle Yazmak
İki vector'ün ne kadar benzer olduğunu ölçmenin standart yolu cosine similarity: aradaki açının kosinüsü. Değer -1 ile 1 arasındadır; 1 aynı yön, 0 ilgisiz, -1 zıt yön demektir. Formül kısa: dot product bölü iki vector'ün uzunluklarının çarpımı. Bir kitap kataloğunda anlamsal arama yapalım. Veriler books.ts'te, her kitap için bir başlık ve tek cümlelik bir açıklama var:
export interface Book {
title: string;
description: string;
}
export const books: Book[] = [
{ title: 'Dune', description: 'A noble family takes control of a desert planet that holds the most valuable resource in the galaxy.' },
{ title: 'The Martian', description: 'An astronaut stranded alone on Mars must grow food and fix his equipment to survive until rescue.' },
{ title: 'Project Hail Mary', description: 'A science teacher wakes up on an interstellar ship with no memory and must save Earth from a dying sun.' },
{ title: 'The Hobbit', description: 'A quiet homebody joins a band of dwarves on a quest to win back their mountain from a dragon.' },
{ title: 'Gone Girl', description: 'On their fifth anniversary a wife disappears, and the husband becomes the main suspect.' },
{ title: 'The Joy of Cooking', description: 'A classic kitchen reference with thousands of recipes, from simple breakfasts to holiday dinners.' },
{ title: 'Clean Code', description: 'Practical advice for software developers on writing readable, maintainable programs.' },
{ title: 'Sapiens', description: 'A short history of humankind, from hunter-gatherers to the modern economy.' },
];
import ollama from 'ollama';
import { books } from './books.js';
// Formül: cos(a, b) = (a . b) / (|a| * |b|)
function cosineSimilarity(a: number[], b: number[]): number {
let dot = 0;
let normA = 0;
let normB = 0;
for (let i = 0; i < a.length; i++) {
dot += a[i] * b[i];
normA += a[i] * a[i];
normB += b[i] * b[i];
}
return dot / (Math.sqrt(normA) * Math.sqrt(normB));
}
async function embed(texts: string[]): Promise<number[][]> {
const response = await ollama.embed({ model: 'nomic-embed-text', input: texts });
return response.embeddings;
}
// Açılışta bir kez indeksle: her kitap için bir vektör
const index = await embed(books.map((b) => `search_document: ${b.title}. ${b.description}`));
async function search(query: string, topK = 3): Promise<void> {
const [queryVector] = await embed([`search_query: ${query}`]);
const ranked = books
.map((book, i) => ({ title: book.title, score: cosineSimilarity(queryVector, index[i]) }))
.sort((x, y) => y.score - x.score)
.slice(0, topK);
console.log(`\n"${query}"`);
for (const r of ranked) {
console.log(` ${r.score.toFixed(3)} ${r.title}`);
}
}
// Karşılaştırma için: aynı açıklamalarda basit bir anahtar kelime araması
function keywordSearch(query: string): string[] {
const words = query.toLowerCase().split(/\W+/).filter((w) => w.length > 3);
return books
.filter((b) => words.some((w) => b.description.toLowerCase().includes(w)))
.map((b) => b.title);
}
for (const query of process.argv.slice(2)) {
await search(query);
console.log(' keyword:', keywordSearch(query));
}
Akış iki parçalı. Açılışta bütün kitapları bir kez embed edip vector'leri bellekte tutuyoruz; bu bizim indeksimiz. Her aramada yalnızca sorguyu embed ediyor, onu bütün kitap vector'leriyle karşılaştırıp en yüksek puanlı üçü alıyoruz. Yanına karşılaştırma olsun diye, dört harften uzun kelimeleri açıklamalarda arayan basit bir anahtar kelime araması koydum.
Bellekte Küçük Bir Semantic Search
$ npx tsx semantic-search.ts "survival on a red planet" "a mystery about a missing person" "a wizard's journey to fight a dragon"
"survival on a red planet"
0.647 The Martian
0.577 Project Hail Mary
0.549 Dune
keyword: [ 'Dune' ]
"a mystery about a missing person"
0.657 Gone Girl
0.608 Project Hail Mary
0.574 The Hobbit
keyword: []
"a wizard's journey to fight a dragon"
0.644 The Hobbit
0.505 Project Hail Mary
0.496 The Joy of Cooking
keyword: [ 'The Hobbit' ]
İlk sorguda anahtar kelime araması "planet" kelimesini Dune'un açıklamasında buldu ve yanlış kitabı getirdi. The Martian'ın açıklamasında "red planet" geçmiyor, "Mars" geçiyor; embedding bu ikisinin aynı yer olduğunu biliyor. İkinci sorguda anahtar kelime araması hiçbir şey bulamadı, çünkü Gone Girl'ün açıklaması "missing" değil "disappears" diyor. Üçüncüde iki yöntem de doğru kitabı buldu: kelime araması "dragon" sayesinde, embedding ise açıklamada hiç geçmeyen "wizard" ve "journey"ye rağmen.
Tek bir şeyi aklında tut: semantic search anahtar kelime aramasının yerine geçmez, onu tamamlar. Ürün kodu, sipariş numarası, ISBN gibi birebir eşleşmesi gereken şeylerde klasik arama hâlâ daha güvenilir. Gerçek sistemler çoğunlukla ikisini birleştirir (hybrid search).
Aynı Arama C#'ta: IEmbeddingGenerator
Microsoft.Extensions.AI'da embedding için ayrı bir soyutlama var: IEmbeddingGenerator<TInput, TEmbedding>. OllamaSharp'ın OllamaApiClient'ı hem bunu hem IChatClient'ı uyguluyor. Aynı kataloğu C#'ta indeksleyip cosine similarity'yi hem elle hem de System.Numerics.Tensors'taki TensorPrimitives.CosineSimilarity ile hesaplayalım; bu paket Microsoft.Extensions.AI'ın bağımlılığı olarak zaten geliyor:
using System.Globalization;
using System.Numerics.Tensors;
using Microsoft.Extensions.AI;
using OllamaSharp;
// Makinenin bölge ayarı ne olursa olsun 0,647 değil 0.647 yazsın
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
// OllamaApiClient yalnızca IChatClient değil, IEmbeddingGenerator'ı da uygular
IEmbeddingGenerator<string, Embedding<float>> generator =
new OllamaApiClient(new Uri("http://localhost:11434"), "nomic-embed-text");
(string Title, string Description)[] books =
[
("Dune", "A noble family takes control of a desert planet that holds the most valuable resource in the galaxy."),
("The Martian", "An astronaut stranded alone on Mars must grow food and fix his equipment to survive until rescue."),
("Project Hail Mary", "A science teacher wakes up on an interstellar ship with no memory and must save Earth from a dying sun."),
("The Hobbit", "A quiet homebody joins a band of dwarves on a quest to win back their mountain from a dragon."),
("Gone Girl", "On their fifth anniversary a wife disappears, and the husband becomes the main suspect."),
("The Joy of Cooking", "A classic kitchen reference with thousands of recipes, from simple breakfasts to holiday dinners."),
("Clean Code", "Practical advice for software developers on writing readable, maintainable programs."),
("Sapiens", "A short history of humankind, from hunter-gatherers to the modern economy."),
];
// Bir kez indeksle: her kitaba bir vektör, nomic'in doküman önekiyle
GeneratedEmbeddings<Embedding<float>> index =
await generator.GenerateAsync(books.Select(b => $"search_document: {b.Title}. {b.Description}"));
static float Cosine(ReadOnlySpan<float> a, ReadOnlySpan<float> b)
{
float dot = 0, normA = 0, normB = 0;
for (int i = 0; i < a.Length; i++)
{
dot += a[i] * b[i];
normA += a[i] * a[i];
normB += b[i] * b[i];
}
return dot / (MathF.Sqrt(normA) * MathF.Sqrt(normB));
}
foreach (string query in args)
{
ReadOnlyMemory<float> q = await generator.GenerateVectorAsync($"search_query: {query}");
Console.WriteLine($"\"{query}\" (dimensions: {q.Length})");
IEnumerable<(string Title, float Manual, float Library)> ranked = books
.Select((book, i) => (book.Title,
Manual: Cosine(q.Span, index[i].Vector.Span),
Library: TensorPrimitives.CosineSimilarity(q.Span, index[i].Vector.Span)))
.OrderByDescending(r => r.Manual)
.Take(3);
foreach ((string title, float manual, float library) in ranked)
{
Console.WriteLine($" {manual:F3} (TensorPrimitives {library:F3}) {title}");
}
}
$ dotnet run -- "survival on a red planet" "a mystery about a missing person" "a wizard's journey to fight a dragon"
"survival on a red planet" (dimensions: 768)
0.647 (TensorPrimitives 0.647) The Martian
0.577 (TensorPrimitives 0.577) Project Hail Mary
0.549 (TensorPrimitives 0.549) Dune
"a mystery about a missing person" (dimensions: 768)
0.657 (TensorPrimitives 0.657) Gone Girl
0.608 (TensorPrimitives 0.608) Project Hail Mary
0.574 (TensorPrimitives 0.574) The Hobbit
"a wizard's journey to fight a dragon" (dimensions: 768)
0.644 (TensorPrimitives 0.644) The Hobbit
0.505 (TensorPrimitives 0.505) Project Hail Mary
0.496 (TensorPrimitives 0.496) The Joy of Cooking
Puanlar TypeScript'tekilerle virgülden sonraki üç haneye kadar aynı; iki dil de aynı modelden aynı vector'leri aldı. Elle yazdığımız fonksiyon ile TensorPrimitives de aynı sonucu veriyor. Gerçek projede TensorPrimitives'i kullan: SIMD ile hızlandırılmış ve test edilmiş.
CultureInfo satırını ilk çalıştırmadan sonra ekledim. Bu makinenin bölge ayarı Türkçe olduğu için ilk çıktıda puanlar 0,647 diye basıldı. Sayıyı ekrana basarken sorun değil, ama aynı string'i bir yere yazıp tekrar okuyorsan sessiz bir hata kaynağı.
Puanlar Ne Söyler, Ne Söylemez
Arama sonuçlarına tekrar bak: "a wizard's journey to fight a dragon" için The Joy of Cooking 0.496 aldı. Bir yemek kitabının ejderha sorusuyla hiçbir ilgisi yok, ama puan sıfıra yakın değil. Bu modelde ilgisiz metinler de 0.4–0.5 bandında kalıyor. Yani puanlar mutlak bir "ilgili/ilgisiz" ölçüsü değil, göreli bir sıralama. Sabit bir eşik (ör. "0.6'nın altını gösterme") koyacaksan onu kendi verinle ölçerek seç ve model değişince yeniden ölç.
Şimdi açılıştaki soruya dönelim. Üç cümleyi clustering: önekiyle embed edip ilk cümleyle karşılaştırdım:
import ollama from 'ollama';
const anchor = 'Your order has shipped.';
const others = [
'Your package is on its way.',
'Your order has not shipped.',
'Your order was cancelled.',
'The weather is nice today.',
];
const { embeddings } = await ollama.embed({
model: 'nomic-embed-text',
input: [anchor, ...others].map((t) => `clustering: ${t}`),
});
// Vektörlerin uzunluğu zaten 1, bu yüzden dot product cosine similarity'ye eşit
const dot = (a: number[], b: number[]) => a.reduce((sum, x, i) => sum + x * b[i], 0);
others.forEach((text, i) => {
console.log(`${dot(embeddings[0], embeddings[i + 1]).toFixed(3)} ${text}`);
});
$ npx tsx pairs.ts
0.873 Your package is on its way.
0.977 Your order has not shipped.
0.837 Your order was cancelled.
0.689 The weather is nice today.
"Your order has not shipped." 0.977 aldı, aynı anlamdaki "Your package is on its way." ise 0.873. Embedding, iki cümlenin aynı konudan bahsettiğini çok iyi yakalıyor; ama "not" gibi küçük bir kelimenin anlamı tersine çevirdiğini neredeyse hiç fark etmiyor. Embedding "hangi metinler aynı konuda?" sorusunun cevabıdır, "hangi metin doğru?" sorusunun değil. RAG yazısında bu ayrım önemli olacak: embedding doğru paragrafı bulur, o paragrafı okuyup doğru cevabı çıkarmak sohbet modelinin işidir.
Türkçe Sorgular ve Çok Dilli Modeller
nomic-embed-text'in model kartında dil olarak yalnızca İngilizce geçiyor. Kataloğumuz İngilizce, peki sorgu Türkçe olursa?
$ npx tsx semantic-search.ts "uzayda tek başına mahsur kalan bir astronot" "kayıp bir kadın ve şüpheli koca" "yemek tarifleri"
"uzayda tek başına mahsur kalan bir astronot"
0.481 Project Hail Mary
0.468 The Hobbit
0.453 The Martian
keyword: []
"kayıp bir kadın ve şüpheli koca"
0.427 Clean Code
0.415 Gone Girl
0.408 The Joy of Cooking
keyword: []
"yemek tarifleri"
0.407 Project Hail Mary
0.393 The Hobbit
0.383 The Joy of Cooking
keyword: []
Üç sorgunun hiçbirinde doğru kitap birinci değil. "Yemek tarifleri" için en üstte bir bilim kurgu romanı var ve bütün puanlar ilgisizlik bandında. Model Türkçe cümleyi anlamıyor; büyük ihtimalle yalnızca "astronot" gibi ortak köklü kelimelerden zayıf bir sinyal alıyor.
Türkçe içerik ya da Türkçe sorgu bekliyorsan çok dilli bir embedding modeli seç. Ollama kütüphanesinde bunun için embeddinggemma (100'den fazla dil, 768 boyut), bge-m3 ve qwen3-embedding gibi seçenekler var. Bu makinede kurulu olmadıkları için çıktı göstermiyorum; ama modeli değiştirdiğinde bütün indeksi yeniden oluşturman gerektiğini unutma. Farklı modellerin vector'leri aynı uzayda değildir, boyutları aynı olsa bile birbirleriyle karşılaştırılamaz.
Sık Düşülen Tuzaklar
Birincisi, farklı modellerden gelen vector'leri karıştırmak. İndeksi bir modelle, sorguyu başka bir modelle embed edersen puanlar anlamsız olur ve hiçbir hata mesajı almazsın. Vector'lerin yanına hangi modelle üretildiklerini de kaydet.
İkincisi, öneki unutmak ya da karışık kullanmak. Aynı kataloğu öneksiz embed edip denedim; bu küçük veride birinci sıradaki kitap değişmedi, ama puanlar kaydı: "a wizard's journey to fight a dragon" için The Hobbit 0.644'ten 0.716'ya çıktı. Bir eşik değeri seçtiysen bu fark onu geçersiz kılar. Model kartı ne diyorsa onu yap ve indeks ile sorgu tarafında aynı kuralı uygula.
Üçüncüsü, uzun metni tek parça embed etmek. Bu makinedeki Ollama, nomic-embed-text için 2048 token'lık bir context length bildiriyor ve /api/embed'in truncate seçeneği varsayılan olarak açık; sınırı aşan metin sessizce kesilir. Ayrıca bir kitabın tamamını tek bir vector'e sıkıştırmak, içindeki ayrıntıları ortalamada kaybettirir. Çözüm metni parçalara (chunk) bölmek; bir sonraki yazının konusu.
Dördüncüsü, indeksi her istekte yeniden hesaplamak. Dokümanların vector'leri değişmez; bir kez hesapla, sakla. Her istekte embed edilmesi gereken tek şey sorgu.
Küçük Bir Deneme
★☆☆ books.ts'e kendi seçtiğin üç kitap ekle ve açıklamalarında hiç geçmeyen kelimelerle arama yap. Embedding doğru kitabı buluyor mu, anahtar kelime araması ne yapıyor?
★★☆ pairs.ts'teki cümleleri search_query: ve search_document: önekleriyle tekrar dene. Olumsuz cümlenin puanı değişiyor mu? Kendi "zıt anlam ama yüksek puan" örneğini bul.
★★☆ semantic-search.ts'e bir eşik ekle: en yüksek puan belirlediğin değerin altındaysa "no good match" yazsın. Bu yazıdaki İngilizce ve Türkçe sorgularla hangi eşiğin işe yaradığını ölç.
★★★ C# programına embed-basics.ts'teki norm hesabını ekle ve vector'lerin birim uzunlukta geldiğini doğrula. Ardından cosine yerine yalnızca TensorPrimitives.Dot kullanan bir sürüm yaz ve puanların değişmediğini göster.
Artık metni anlamına göre bulabiliyoruz. Bir sonraki yazıda bunu sohbet modeliyle birleştireceğiz: kargo ve iade politikalarımızı parçalara bölüp embed edecek, soruya en yakın parçaları prompt'a koyacak ve gemma3:4b'nin hiç görmediği bilgilerle doğru cevap vermesini, bilmediğinde de "bilmiyorum" demesini sağlayacağız. Bunun adı RAG.