Doküman Ingestion: PDF, HTML ve Markdown'dan Temiz Metin, Metadata ve Artımlı Güncelleme
RAG'in kalitesi ingestion'da başlar: PDF'ten gelen "fi" ligature'ı Contains("fiyat") aramasını false yaptı, sayfa numarası bir bölümün ortasına girdi, HTML menüsü chunk'a karıştı. Temiz metin, chunk başına metadata, Microsoft.Extensions.DataIngestion (preview) ile pipeline ve kısmi başarı, içerik hash'iyle artımlı güncelleme ve silinen dokümanların index'ten çıkarılması.
Sürüm notu: Bu yazıdaki kodlar .NET 10, PdfPig 0.1.16, AngleSharp 1.10.0, Microsoft.Extensions.DataIngestion 10.11.0-preview.1, CommunityToolkit.VectorData.PgVector 1.0.2 ve Npgsql 10.0.3 ile derlenip PostgreSQL 17.11 + pgvector 0.8.7 ve Ollama 0.34.4 (nomic-embed-text) üzerinde denenmiştir.
Önce Tahmin Et
Bookstore'un kurumsal satış sözleşmesi iki sayfalık bir PDF (Portable Document Format). İçinde "liste fiyatı üzerinden %15 indirim" diye bir cümle var. PDF'ten metni bir kütüphaneyle çıkarıp text.Contains("fiyat") diye arıyorsun. Sonuç true mu?
Göründüğü kadar saçma bir soru değil. Bu yazıda RAG (Retrieval-Augmented Generation) hattının en az konuşulan ama en çok hata üreten kısmına bakıyoruz: dokümanı okumak, temizlemek, metadata'sıyla birlikte chunk'lara taşımak ve dokümanlar değiştikçe index'i güncel tutmak. İlk yazıdaki "eski veri" ve "chunk sınırı" hatalarının ikisi de burada doğuyor.
Her Format Kendi Gürültüsüyle Gelir
Doküman klasörümüzde üç format var: Markdown politika dosyaları, yardım merkezinden alınmış bir HTML (HyperText Markup Language) sayfası ve PDF sözleşme. Hepsini aynı yapıya, başlığı ve gövdesi olan bölümlerin listesine (Section) çevirmek istiyoruz; chunker'ın formatı bilmesine gerek kalmasın.
Markdown en kolayı: başlıklar zaten işaretli, metadata da dosyanın başındaki bloktan okunuyor. HTML'de ise asıl metin, menünün, çerez bandının ve footer'ın arasında kaybolur. AngleSharp ile sayfanın body metnini olduğu gibi alırsak:
$ dotnet run -- html-naive
Ana Sayfa | Kampanyalar | Yardım | Hesabım | Sepetim (0) Bu site çerez kullanır. Devam ederek çerez politikasını kabul etmiş olursun. Tamam Sıkça Sorulan Sorular Siparişimi nasıl iptal ederim? Siparişin kargoya verilmeden önce Hesabım > Siparişlerim sayfasından iptal edebilirsin. Kargoya verilmiş siparişler iptal edilemez; teslim aldıktan sonra iade talebi oluşturman gerekir. Fatura adresimi değiştirebilir miyim? Fat ...
Chunk'ın ilk cümlesi "Ana Sayfa | Kampanyalar | Yardım | Hesabım | Sepetim (0)". Bu menü ve çerez metni her sayfada aynı olduğu için, embedding'e girdiğinde bütün yardım sayfalarını birbirine benzetir ve "çerez politikası" gibi alakasız sorulara cevap olarak gelir. Temizlik, gürültüyü yapısından tanıyıp atmak:
public static SourceDocument LoadHtml(string path, string rel)
{
var document = new HtmlParser().ParseDocument(File.ReadAllText(path));
string Meta(string name) =>
document.QuerySelector($"meta[name='bookstore:{name}']")?.GetAttribute("content") ?? "";
// Gürültü: menü, çerez bandı, footer ve script'ler embedding'e girmesin
foreach (var noise in document.QuerySelectorAll("script, style, nav, header, footer, .cookie").ToList())
{
noise.Remove();
}
List<Section> sections = document.QuerySelectorAll("main section")
.Select(s => new Section(
s.QuerySelector("h2")!.TextContent.Trim(),
string.Join("\n\n", s.QuerySelectorAll("p").Select(p => p.TextContent.Trim()))))
.ToList();
string title = document.QuerySelector("main h1")!.TextContent.Trim();
return new(Meta("id"), title, Meta("tenant"), Meta("acl"),
DateOnly.Parse(Meta("updated_at"), CultureInfo.InvariantCulture), rel, sections);
}
Metadata (id, acl, updated_at) sayfanın meta etiketlerinden geliyor. Gerçek bir yardım merkezinde bu bilgiyi CMS'in (Content Management System) API'sinden (Application Programming Interface) almak daha sağlam; sayfayı kazımak son çare.
PDF'ten Temiz Metin
PDF bir metin formatı değil, bir çizim formatı: sayfanın belli koordinatlarına belli glyph'leri yerleştirir. "Paragraf", "başlık" ya da "tablo" kavramı yoktur; bunları koordinatlardan geri çıkarmak gerekir. PdfPig'in sayfa metnine olduğu gibi bakalım:
$ dotnet run -- pdf-naive
[page 1] Kurumsal Satış SözleşmesiBu sözleşme, okul, kütüphane, şirket ve kamu kurumlarının (bundan sonra "Kurum") Bookstore'dantoplu kitap alımına ilişkin koşulları düzenler. Yürürlük tarihi: 1 Eylül 2026.1. Toplu alım indirimiAynı siparişte aynı kitaptan en az 50 adet alan Kurum, liste fiyatı üzerinden %15 indirim alır. 200adet ...
Üç sorun bir arada. Satır sonları kaybolmuş ve kelimeler yapışmış: "SözleşmesiBu", "Bookstore'dantoplu", "200adet". Tablo tek bir anlamsız diziye dönüşmüş (çıktının devamında "Adetİndirim50-199%15200 ve üzeri%22"). Üçüncüsü gözle görülmüyor: "liste fiyatı" kelimesindeki "fi" iki harf değil, tek bir Unicode karakteri (U+FB01, "fi" ligature'ı). Açılıştaki sorunun cevabı:
$ dotnet run -- ligature
Contains("fiyat"): False
NFKC Contains("fiyat"): True
o200k tokens: 4 vs 3
Contains("fiyat") false döndü. Ligature'lı metin keyword aramasında hiç bulunmaz, embedding tarafında da farklı token'lara bölünür. Unicode'un NFKC (Normalization Form Compatibility Composition) normalizasyonu bu tür uyumluluk karakterlerini karşılıklarına çeviriyor. Türkçe harfler (ı, İ, ş, ğ) bu dönüşümden etkilenmiyor; yine de normalizasyonu kendi metninde test etmeden uygulama.
İkinci adım satırları geri kurmak: aynı baseline'daki kelimeleri soldan sağa birleştir, satırları yukarıdan aşağı sırala. Sonra header ve footer: her sayfada tekrar eden satırlar. İlk denememde birebir aynı satırları attım:
$ dotnet run -- pdf-v1
...
Kurumsal siparişler, stok durumuna bağlı olarak sipariş onayından sonra 10 iş günü içinde
Kurum'un bildirdiği tek adrese teslim edilir. Teslimat ücreti 100 adet ve üzeri siparişlerde
Bookstore'a, daha küçük siparişlerde Kurum'a aittir.
Sayfa 1 / 2
Birden fazla adrese dağıtım isteniyorsa her ek adres için ayrı teslimat ücreti alınır. Teslimatta eksik
veya hasarlı ürün, teslim tutanağına işlenmeli ve 3 iş günü içinde yazılı olarak bildirilmelidir.
...
Header ve sabit footer satırı gitti, ama "Sayfa 1 / 2" kaldı ve "3. Teslimat" bölümünün ortasına girdi; çünkü ikinci sayfada aynı satır "Sayfa 2 / 2". Çözüm, karşılaştırmadan önce rakamları maskelemek. Son hali:
List<List<string>> pages = [];
using (PdfDocument pdf = PdfDocument.Open(path))
{
foreach (var page in pdf.GetPages())
{
// Kelimeleri satırlara grupla: aynı baseline (Y) = aynı satır
List<string> pageLines = page.GetWords()
.GroupBy(w => Math.Round(w.BoundingBox.Bottom))
.OrderByDescending(g => g.Key)
.Select(g => string.Join(' ', g.OrderBy(w => w.BoundingBox.Left).Select(w => w.Text)))
.Select(line => line.Normalize(NormalizationForm.FormKC)) // "fi" -> "fi", "fl" -> "fl"
.ToList();
pages.Add(pageLines);
}
}
// Her sayfada tekrar eden satır = header/footer; rakamları maskele ki "Sayfa 1 / 2" de yakalansın
static string Shape(string line) => Digits().Replace(line, "#");
HashSet<string> repeated = pages.Count > 1
? pages.Skip(1).Aggregate(pages[0].Select(Shape).ToHashSet(),
(set, p) => { set.IntersectWith(p.Select(Shape)); return set; })
: [];
Bölümler "1. Toplu alım indirimi" gibi numaralı başlıklardan ayrılıyor ve bir bölüm sayfa sınırını geçse bile tek bölüm olarak kalıyor. Tablo hâlâ düz metin ("Adet İndirim 50-199 %15 200 ve üzeri %22"); ilk yazıda modelin 120 adet için %22 demesinin bir sebebi de bu. Tabloları yapısıyla korumak için ya PDF'i üreten sistemden veriyi almak ya da tablo tanıyan bir parser kullanmak gerekir; düz metin çıkarıcı bunu yapmaz.
Her Chunk'a Metadata
Ingestion'ın ürettiği şey yalnız metin ve vektör değil. Serinin doc_chunks tablosunda her chunk şu bilgileri taşıyor ve her birinin bir sebebi var:
doc_idvesource_path: citation, silme ve yeniden işleme için dokümana geri dönüş yolu.titlevesection: başlık yolu; chunk metninin başına da ekleniyor, çünkü "Hediye kartıyla ödenen siparişlerde..." cümlesi tek başına hangi politikaya ait olduğunu söylemez.tenantveacl: retrieval filtresi; ilk yazıdaki personel indirimi sızıntısının ilacı.updated_at: tazelik filtresi ve "bu bilgi ne kadar güncel?" sorusunun cevabı.content_hash: dokümanın tamamının hash'i; değişmeyen dokümanı yeniden embed etmemek için.embedding_model: vektörün hangi modelle üretildiği; ikinci yazıdaki model değişimi tuzağına karşı.
Sayfa numarası PDF'ler için de değerli bir alan (citation'da "sözleşme, sayfa 2" diyebilmek için); bizim iki sayfalık sözleşmede bölüm numarası yeterli olduğu için tutmadım. Kural şu: retrieval sırasında filtrelemek ya da cevapta göstermek isteyeceğin her şeyi ingestion anında chunk'a yaz. Sonradan eklemek, bütün dokümanları yeniden işlemek demek.
Microsoft.Extensions.DataIngestion ile Aynı Hat
.NET'in kendi ingestion library'si Microsoft.Extensions.DataIngestion henüz preview (bu yazıdaki sürüm 10.11.0-preview.1). Dört yapı taşı var: dokümanı IngestionDocument'a çeviren reader (Markdig ve MarkItDown paketleri), onu bölen chunker (HeaderChunker, SectionChunker, SemanticSimilarityChunker, DocumentTokenChunker), chunk'ları zenginleştiren processor'lar ve sonucu bir vector store'a yazan VectorStoreWriter<T>. IngestionPipeline<T> hepsini birleştiriyor:
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DataIngestion;
using Microsoft.Extensions.Logging;
using Microsoft.ML.Tokenizers;
using CommunityToolkit.VectorData.PgVector;
using OllamaSharp;
string connectionString = $"Host=localhost;Port=19701;Database=ragmedi;Username=postgres;Password={Environment.GetEnvironmentVariable("PGPW")}";
IEmbeddingGenerator<string, Embedding<float>> embedder =
new OllamaApiClient(new Uri("http://localhost:11434"), "nomic-embed-text");
// Reader: Markdown'ı IngestionDocument'a (section, header, paragraph, table) çevirir
IngestionDocumentReader reader = new MarkdownReader();
// Chunker: başlıklara göre böl, chunk başına en fazla 300 token
Tokenizer tokenizer = TiktokenTokenizer.CreateForEncoding("o200k_base");
IngestionChunker<string> chunker = new HeaderChunker(new IngestionChunkerOptions(tokenizer)
{
MaxTokensPerChunk = 300,
OverlapTokens = 0
});
// Writer: chunk'ları embed edip pgvector'daki "medi_chunks" collection'ına yazar
using PostgresVectorStore store = new(connectionString, new PostgresVectorStoreOptions { EmbeddingGenerator = embedder });
using VectorStoreWriter<string> writer = new(store, dimensionCount: 768, new VectorStoreWriterOptions
{
CollectionName = "medi_chunks",
IncrementalIngestion = true
});
using ILoggerFactory loggerFactory = LoggerFactory.Create(b => b.AddSimpleConsole(o => o.SingleLine = true).SetMinimumLevel(LogLevel.Warning));
using IngestionPipeline<string> pipeline = new(reader, chunker, writer, loggerFactory: loggerFactory);
await foreach (IngestionResult result in pipeline.ProcessAsync(new DirectoryInfo(args[0]), "*.md", SearchOption.AllDirectories))
{
string status = result.Succeeded ? "ok" : $"FAILED {result.Exception?.GetType().Name}: {result.Exception?.Message}";
Console.WriteLine($"{result.DocumentId,-60} {status}");
}
Bu programı politika ve ürün dosyalarının bir kopyası üzerinde çalıştırdım. Klasöre kasıtlı olarak okunamayan bir dosya (kampanya.md, izinleri kapatılmış) ekledim:
$ dotnet run -- demo/medi
demo/medi/products/urun-katalogu.md ok
demo/medi/policies/hediye-karti.md ok
demo/medi/policies/kampanya.md FAILED UnauthorizedAccessException: Access to the path 'demo/medi/policies/kampanya.md' is denied.
fail: Microsoft.Extensions.DataIngestion.IngestionPipeline[6] An error occurred while ingesting document 'demo/medi/policies/kampanya.md'. ...
demo/medi/policies/odeme-secenekleri.md ok
demo/medi/policies/kargo-ve-teslimat.md ok
demo/medi/policies/iade-politikasi.md ok
demo/medi/policies/sadakat-programi.md ok
Okunamayan dosya bütün çalışmayı durdurmadı: ProcessAsync her doküman için bir IngestionResult döndürüyor, hatalı olanın Succeeded değeri false ve Exception'ı dolu; pipeline'ın logger'ı da ayrıca bir fail satırı yazdı. Diğer altı dosyadan 36 chunk yazıldı. HeaderChunker başlık yolunu hem chunk'ın Context'ine hem metnin başına koyuyor ("# Hediye Kartı ## Geçerlilik süresi"); bizim chunker'ın yaptığının aynısı. Writer'ın kendi oluşturduğu tabloya bakalım:
\d medi_chunks
Table "public.medi_chunks"
Column | Type | Collation | Nullable | Default
------------+-------------+-----------+----------+-------------------
key | uuid | | not null | gen_random_uuid()
content | text | | |
context | text | | |
documentid | text | | |
embedding | vector(768) | | |
Indexes:
"medi_chunks_pkey" PRIMARY KEY, btree (key)
"medi_chunks_documentid_index" btree (documentid)
Varsayılan şemada key, content, context, documentid ve embedding var; acl, tenant ya da updated_at yok. Markdown dosyalarının başındaki metadata bloğu hiçbir chunk'a taşınmadı (acl: geçen chunk sayısı sıfır) ve documentid dosyanın tam yolu. Yani bu haliyle yetki filtresi kurulamaz; metadata için bir chunk processor yazman ya da kendi kayıt tipinle bir writer kullanman gerekiyor. IncrementalIngestion = true ise bir dokümanı yeniden işlediğinde eski chunk'larını siliyor. Silinen dosyalar için ne yaptığına bakalım: ürün kataloğunu klasörden çıkarıp pipeline'ı yeniden çalıştırdım:
$ mv demo/medi/products demo/products_removed
$ dotnet run -- demo/medi
demo/medi/policies/hediye-karti.md ok
demo/medi/policies/odeme-secenekleri.md ok
demo/medi/policies/kargo-ve-teslimat.md ok
demo/medi/policies/iade-politikasi.md ok
demo/medi/policies/sadakat-programi.md ok
SELECT document, count(*) FROM medi_chunks GROUP BY 1;
document | count
-------------------------------+-------
policies/hediye-karti.md | 3
policies/iade-politikasi.md | 5
policies/kargo-ve-teslimat.md | 6
policies/odeme-secenekleri.md | 4
policies/sadakat-programi.md | 4
products/urun-katalogu.md | 14
(6 rows)
Politika dosyaları yeniden işlendi ve chunk sayıları aynı kaldı; yani tekrar yok, eski chunk'lar gerçekten değiştirildi. Ama urun-katalogu.md artık klasörde olmadığı halde 14 chunk'ı index'te duruyor. Pipeline yalnız kendisine verilen dosyaları biliyor; olmayan bir dosyanın chunk'larını silmek senin işin.
Artımlı Güncelleme: Hash, Upsert ve Silme
Ingestion bir kez çalışıp bitmez. Kargo politikası değişir, yeni ürünler eklenir, eski sözleşme kaldırılır. Her seferinde her şeyi yeniden embed etmek küçük bir klasörde dert değil, ama yüz binlerce chunk'ta saatler ve gereksiz maliyet demek. Artımlı güncellemenin üç kuralı var: değişmeyeni atla, değişenin eski chunk'larını yenileriyle tek transaction'da değiştir, kaynakta artık olmayanı index'ten sil.
public sealed record SyncReport(int Added, int Updated, int Unchanged, int Removed, int EmbeddedChunks, long ElapsedMs);
public sealed class IncrementalIndexer(NpgsqlDataSource dataSource, IEmbeddingGenerator<string, Embedding<float>> embedder)
{
public static string DocumentHash(SourceDocument doc) =>
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(
string.Join("\n", doc.Sections.Select(s => s.Heading + "\n" + s.Body)))))[..16];
public async Task<SyncReport> SyncAsync(IReadOnlyList<SourceDocument> source, CancellationToken ct = default)
{
Stopwatch sw = Stopwatch.StartNew();
await using NpgsqlConnection conn = await dataSource.OpenConnectionAsync(ct);
// Index'te hangi doküman hangi hash'le duruyor?
Dictionary<string, string> indexed = [];
await using (NpgsqlCommand cmd = new("SELECT DISTINCT doc_id, content_hash FROM doc_chunks", conn))
await using (NpgsqlDataReader r = await cmd.ExecuteReaderAsync(ct))
{
while (await r.ReadAsync(ct)) indexed[r.GetString(0)] = r.GetString(1);
}
int added = 0, updated = 0, unchanged = 0, embedded = 0;
foreach (SourceDocument doc in source)
{
string hash = DocumentHash(doc);
if (indexed.TryGetValue(doc.Id, out string? old) && old == hash)
{
unchanged++;
continue;
}
// Değişen doküman: chunk'la, embed et, eski chunk'ları silip yenilerini tek transaction'da yaz
List<Chunk> chunks = Chunkers.Section([doc]);
GeneratedEmbeddings<Embedding<float>> vectors =
await embedder.GenerateAsync(chunks.Select(c => "search_document: " + c.Content), cancellationToken: ct);
await using NpgsqlTransaction tx = await conn.BeginTransactionAsync(ct);
await using (NpgsqlCommand del = new("DELETE FROM doc_chunks WHERE doc_id = @id", conn, tx))
{
del.Parameters.AddWithValue("id", doc.Id);
await del.ExecuteNonQueryAsync(ct);
}
for (int i = 0; i < chunks.Count; i++)
{
Chunk c = chunks[i];
await using NpgsqlCommand ins = new("""
INSERT INTO doc_chunks (id, doc_id, title, section, content, source_path, tenant, acl,
updated_at, content_hash, embedding_model, embedding)
VALUES (@id, @doc, @title, @section, @content, @path, @tenant, @acl, @updated, @hash, @model, @embedding)
""", conn, tx);
ins.Parameters.AddWithValue("id", c.Id);
ins.Parameters.AddWithValue("doc", c.DocId);
ins.Parameters.AddWithValue("title", c.Title);
ins.Parameters.AddWithValue("section", c.Section);
ins.Parameters.AddWithValue("content", c.Content);
ins.Parameters.AddWithValue("path", c.SourcePath);
ins.Parameters.AddWithValue("tenant", c.Tenant);
ins.Parameters.AddWithValue("acl", c.Acl);
ins.Parameters.AddWithValue("updated", c.UpdatedAt);
ins.Parameters.AddWithValue("hash", hash);
ins.Parameters.AddWithValue("model", vectors[i].ModelId ?? "unknown");
ins.Parameters.AddWithValue("embedding", new Vector(vectors[i].Vector));
await ins.ExecuteNonQueryAsync(ct);
}
await tx.CommitAsync(ct);
embedded += chunks.Count;
if (old is null) added++; else updated++;
}
// Kaynakta artık olmayan dokümanların vektörlerini sil
string[] gone = indexed.Keys.Except(source.Select(d => d.Id)).ToArray();
await using (NpgsqlCommand del = new("DELETE FROM doc_chunks WHERE doc_id = ANY(@ids)", conn))
{
del.Parameters.AddWithValue("ids", gone);
await del.ExecuteNonQueryAsync(ct);
}
return new(added, updated, unchanged, gone.Length, embedded, sw.ElapsedMilliseconds);
}
}
Doküman hash'i bölümlerin başlık ve gövdesinden hesaplanıyor, yani çıkarılmış ve temizlenmiş metinden. Dosyanın byte'larından hash almak da mümkün, ama o zaman parser'ı ya da temizlik kuralını değiştirdiğinde değişmeyen dokümanlar yeniden işlenmez; metinden alınan hash bunu da yakalar. Silme ve ekleme aynı transaction'da: arada bir sorgu gelirse ya eski chunk'ları ya yenileri görür, yarım bir dokümanı asla. Senaryoyu dört adımda çalıştırdım:
$ dotnet run -- sync demo/sync --archive
SyncReport { Added = 11, Updated = 0, Unchanged = 0, Removed = 0, EmbeddedChunks = 55, ElapsedMs = 257 }
$ dotnet run -- sync demo/sync
SyncReport { Added = 0, Updated = 0, Unchanged = 10, Removed = 1, EmbeddedChunks = 0, ElapsedMs = 38 }
$ dotnet run -- sync demo/sync
SyncReport { Added = 0, Updated = 0, Unchanged = 10, Removed = 0, EmbeddedChunks = 0, ElapsedMs = 36 }
$ sed -i "" "s/400 TL/450 TL/g" demo/sync/policies/kargo-ve-teslimat.md
$ dotnet run -- sync demo/sync
SyncReport { Added = 0, Updated = 1, Unchanged = 9, Removed = 0, EmbeddedChunks = 6, ElapsedMs = 176 }
İlk çalıştırma, eski bir index'in durumunu taklit ediyor: arşiv klasörü de kaynağa dahil, 11 dokümanın 55 chunk'ı yazıldı. İkinci çalıştırmada arşiv klasörü artık kaynak değil: 10 doküman değişmediği için atlandı, sıfır embedding üretildi ve 2025 tarihli eski kargo politikası index'ten silindi; ilk yazıdaki "aynı gün teslimat" cevabının kaynağı böylece kayboldu. Üçüncü çalıştırma hiçbir şey yapmadı: idempotent. Dördüncüde kargo politikasında ücretsiz kargo eşiğini 400'den 450 TL'ye çıkardım; yalnız o doküman güncellendi ve yalnız onun 6 chunk'ı yeniden embed edildi. Aynı değişikliği "her şeyi baştan yükle" yaklaşımıyla yapsaydık 53 chunk embed edilecekti. ElapsedMs değerlerini hız ölçümü olarak okuma: bu deneylerde daha önce üretilmiş embedding'ler çalışma klasöründeki bir disk cache'inden geldi; yalnız dördüncü adımdaki 6 yeni chunk Ollama'ya gitti.
Bu sync'i çalıştıracak şey bir background job: periyodik (her gece ya da her saat) ya da doküman sisteminden gelen bir değişiklik event'iyle tetiklenen. Hangisini seçeceğin ve job'u nasıl güvenilir çalıştıracağın Background Job'lar serisinin konusu; RAG açısından önemli olan, job'un idempotent olması. Aynı sync'i iki kez çalıştırmak ikinci seferde hiçbir şey yapmamalı; hash kontrolü bunu sağlıyor.
Üretimde Dikkat
Her ingestion çalışmasının bir raporu olsun: kaç doküman eklendi, değişti, atlandı, silindi, kaçı hata verdi ve kaç chunk embed edildi. Bir gece "silindi: 412" görürsen, birinin klasörü yanlış yere taşıdığını müşteriden önce fark edersin. Silme sayısına bir üst sınır koymak (ör. index'in %20'sinden fazlasını tek seferde silme, onay iste) ucuz bir sigorta.
Kısmi başarıyı tasarla. Bir dosyanın bozuk olması bütün ingestion'ı durdurmamalı; ama hatalı dosya sessizce kaybolmamalı da. Hataları dosya adıyla logla ve bir sonraki çalışmada yeniden dene.
Parser ve temizlik kurallarını sürümle. PDF'teki header temizliği gibi bir kuralı değiştirdiğinde etkilenen bütün dokümanların yeniden işlenmesi gerekir. Metinden alınan hash bunu otomatik yapıyor; byte hash'i kullanıyorsan bir "pipeline sürümü" alanı ekle.
Dokümanın aslını sakla. Chunk'lar bir türev; bir gün chunk boyutunu, embedding modelini ya da metadata şemasını değiştirdiğinde her şeyi kaynaktan yeniden üretebilmelisin.
Sık Düşülen Tuzaklar
Birincisi, PDF'ten gelen metne güvenmek. Ligature, yapışık kelimeler, sayfaların ortasına giren header ve footer, düzleşmiş tablolar. Ingestion'a birkaç örnek PDF için "beklenen metin" testi yaz; parser güncellemesi bunları bozarsa hemen görürsün.
İkincisi, preview bir API'ye sıkı bağlanmak. Microsoft.Extensions.DataIngestion umut verici, ama preview; isimler ve davranışlar sürümler arasında değişebilir. Kendi Section ve Chunk tiplerin arasında ince bir adaptör tutarsan, library değiştiğinde tek bir dosyayı düzeltirsin.
Üçüncüsü, metadata'yı sonradan eklemeye çalışmak. İlk yazıda acl alanı dokümanda vardı ama chunk'a taşınmadığı için yetki filtresi kurulamadı. Metadata'yı ingestion'ın ilk gününden chunk'la birlikte yaz.
Dördüncüsü, silinen dokümanları unutmak. Yalnız var olan dosyaları işleyen bir ingestion, silinen dokümanın vektörlerini sonsuza kadar index'te bırakır; ilk yazıdaki "aynı gün teslimat" cevabı tam olarak böyle doğdu.
Beşincisi, gürültüyü chunk'a almak. Menü, çerez bandı, sayfa numarası ve "Gizli değildir" gibi her sayfada tekrar eden satırlar embedding'leri birbirine benzetir ve recall'u düşürür.
Küçük Bir Deneme
★☆☆ LoadPdf'teki Normalize(NormalizationForm.FormKC) satırını kaldır ve "Kurumsal siparişlerde fiyat bilgisi gizli mi?" sorusu için ikinci yazıdaki recall düzeneğiyle "5. Gizlilik" bölümünün sırasına bak. Ligature retrieval'ı etkiliyor mu?
★★☆ IncrementalIndexer'a bir güvenlik kuralı ekle: tek bir sync'te index'teki dokümanların %20'sinden fazlası silinecekse silme yapmadan exception fırlat. Kaynak klasörü boş bir klasörle değiştirerek dene.
★★★ PDF bölümlerine sayfa numarası ekle: Section'a Page alanı koy, bölüm başlığının geçtiği sayfayı kaydet ve doc_chunks'a bir page kolonu ekle. "3. Teslimat" bölümü iki sayfaya yayıldığı için hangi sayfayı yazacağına sen karar ver ve kararını gerekçelendir.
Artık temiz metnimiz, metadata'mız ve güncel kalan bir index'imiz var. Ama hangi büyüklükte parçalara bölmeliyiz? Bir sonraki yazıda aynı soru setiyle sabit boyut, başlık tabanlı, semantic ve parent-child chunking'i karşılaştırıyor ve Anthropic'in Contextual Retrieval yöntemini deniyoruz.