İçeriğe geç

A · Ham veri ve temizlik

bir kere (ingest)her sorudaYükleTemizleChunk'laEmbedVector DBSoruEmbedAraRerankPromptCevap
7 yıllık bir çalışanın yıllık izni kaç iş günü?
İngilizce, logların yazdığı gibi: “How many working days of annual leave does an employee with 7 years of service get?”
→ “Typically, an employee with 7 years of service gets 25-30 working days of annual leave, depending on the company's policy.” ✗ yanlış
bare model, no documents. Henüz tek bir doküman okumadık.

Model bu dosyaları hiç görmedi; görse de ham hâlleri kirli. Bu metni olduğu gibi parçalara bölersek gürültü de parçaların içine girer.

gerçek projedeburadan başlardokümanlarPDF · Word · HTMLmetniçıkarham veriatölyeburadan başlarham vericorpus/2025/hr-leave.mdatölyede atlandı:metin hazır geldi1-load.tsokumetadata+ metindata/1-docs.json2-clean.tstemizletemiz metindata/2-clean.jsonbu sayfa: bizim kodumuz, model yok
—id: hr-leavetitle: Annual Leave Policyedition: “2025”department: Human Resourcesaccess: alllang: en—Kraken Air | Annual Leave Policy | Edition 2025CONFIDENTIAL – Internal use only# Annual Leave Policy## 1. Purpose and ScopeThis policy … annual paid leave, specialleave and unpaid leave. It also says ……Page 1 of 4ham veri:hr-leave.mdmetadata→ data/1-docs.jsongürültü4 kural → atılırbölünmüş satır→ tek satıra birleşirtemiz metin →data/2-clean.json
ham vericorpus/2025/8 .md dosyasısrc/steps/1-load.tsloadCorpus()her .md dosyasını okurparseFrontMatter()— bloğunu ayırırwriteStep()JSON dosyasına yazardata/1-docs.json8 kayıt (Doc)metadata + body1-load.ts yazar, 2-clean.ts okurdata/1-docs.json8 kayıt (Doc)body = ham metinsrc/steps/2-clean.tsreadStep()1-docs.json’ı okurcleanText()gürültüyü atar,satırları birleştirirwriteStep()JSON dosyasına yazardata/2-clean.json8 kayıt (CleanDoc)metadata + text
  1. Ham veri nedir?

    Şirketin bilgisi PDF, Word ya da HTML dosyalarında durur. Bu dosyalardan çıkarılan metne ham veri diyoruz. İçeriği doğrudur ama dosyanın izlerini taşır: sayfa başlıkları, sayfa numaraları, menüler.

  2. Bizim ham verimiz: hr-leave.md

    Atölyede metni çıkarma işini atlıyoruz. corpus/ içindeki her dosya PDF’ten çıkmış metin gibi görünüyor. Sayfa başlıklarını, numaraları ve bölünmüş satırları bilerek ekledik; temizlenecek gerçek bir şey olsun diye.

  3. İki adım, model yok

    1-load.ts dosyayı okur ve üstteki etiket bloğunu asıl metinden ayırır. 2-clean.ts metni temizler. İkisinde de model çalışmıyor, yalnızca bizim kodumuz.

  4. Üstteki blok: metadata

    Dosyanın en üstünde, iki --- çizgisi arasında doküman kendini tanıtıyor: kimliği (id), sürümü (edition), bölümü, kimin okuyabileceği (access), dili (lang). Buna metadata diyoruz. 1-load.ts bu bloğu alan alan saklar.

    npm run step -- 1, sonra data/1-docs.json: hr-salary-bands dokümanını yalnızca İK’nın okuyabildiğini hangi alan gösteriyor?
  5. Gerçek bir PDF'te bu blok olur mu?

    Olmaz. Gerçek projede sürümü ve okuma yetkisini dokümanın durduğu sistem tutar: SharePoint, Confluence ya da bir arşiv. Biz her şey tek dosyada dursun diye bunları dosyanın başına yazdık.

  6. Metadata sonra filtre olur

    hr-salary-bands dokümanı access: hr-only taşıyor: yalnızca İK okuyabilir. Etiketi şimdi okuyoruz, soru anında kontrol ediyoruz (E sayfası). Etiket en baştan kayıtta olmazsa sonra filtrelenecek bir şey kalmaz.

  7. Alttaki metin: PDF'in izleri

    hr-leave 4 sayfalık bir PDF’ten çıkmış gibi: her sayfanın başında aynı başlık ve gizlilik satırı, sonunda Page 1 of 4 gibi bir numara. Bu satırlar içerik değil; onlara gürültü diyoruz.

  8. Gürültü neden sorun?

    Başlık satırı her sayfada tekrar eder; metni bölünce birçok parçanın içine girer. Bu ortak satır parçaları birbirine daha çok benzetir ve aramanın onları ayırt etmesini zorlaştırabilir.

  9. Bir de bölünmüş satırlar

    Sayfa genişliği cümleleri de bölmüş: “special” bir satırın sonunda, “leave” sonrakinin başında. İnsan için sorun yok; satır satır işleyen bir program için bunlar iki ayrı parça.

  10. Temizlik: 4 kural ve bir birleştirici

    2-clean.ts gürültüyü 4 kuralla (regex) atar: sayfa başlığı, İngilizce gizlilik satırı, İngilizce ve Türkçe sayfa numarası. Sonra bölünmüş satırları birleştirir; başlık, tablo ve boş satırlar kalır.

    src/steps/2-clean.ts içinde NOISE listesinin ilk kuralını kapatın, npm run step -- 2: hr-leave −8 gürültü satırı göstermeli. Sonra kuralı geri açıp npm run step -- 2 komutunu tekrar çalıştırın.
  11. Sonuç: ham veri girdi, temiz metin çıktı

    8 dokümanda 1078 satır 512 satıra indi. Eksilen 566 satırın 74’ü atılan gürültü; gerisi birleşen satırlar ve silinen fazla boş satırlar. İki adım birkaç milisaniye sürdü.

  12. 1-load.ts içinde: corpus → 1-docs.json

    Class yok: her adım tek dosyada, düz fonksiyonlarla yazıldı. npm run step -- 1, src/steps/1-load.ts dosyasını çalıştırır; 8 dosya okunur, 8 kayıt data/1-docs.json dosyasına yazılır.

  13. 2-clean.ts içinde: 1-docs.json → 2-clean.json

    2-clean.ts kodu 1-load.ts kodunu çağırmaz; onun yazdığı data/1-docs.json dosyasını okur. Temizlenen metin text alanına girer, metadata olduğu gibi kopyalanır, sonuç data/2-clean.json dosyasına yazılır.

    Aşağıdaki “Kaçan gürültü nereye gider?” bölümünü okuyup turkishConfidential kuralını yazın. Kontrol: npm run step -- 2. it-security satırı −8 yerine −12 gürültü satırı göstermeli.

Günün sorusunun cevabı §3’te. Ham dosyada §3’ün ilk paragrafı üç satıra bölünmüş; temizlikten sonra data/2-clean.json içinde tek satır.

corpus/2025/hr-leave.md — ham hâli
## 3. Annual Paid Leave
Part-time staff get the same number as full-time staff. The HR portal
shows the balance for each person. Questions go to the unit's HR
business partner.
| Years of service (full years) | Annual paid leave (working days) |
|---|---|
| 1 | 16 |
| 2 | 16 |

run() işi loadCorpus() fonksiyonuna verir: her .md dosyası okunur, parseFrontMatter() ile ikiye ayrılır, dosya başına bir kayıt döner. Metadata alanlarından biri eksikse hata burada çıkar:

src/steps/1-load.ts
/**
* What it does: turns each file of one edition into a document with labels and text.
*
* Read one edition of the corpus.
* @param edition folder name under corpus/, e.g. "2025"
* @returns one Doc per file: metadata from the front matter, body untouched
*/
export async function loadCorpus(edition: string): Promise<Doc[]> {
const files = await readEditionFiles(edition);
return files.map(({ path, raw }) => {
const { meta, body } = parseFrontMatter(raw);
const missing = KEYS.filter((k) => !meta[k]);
if (missing.length) throw new Error(`${path}: front matter is missing ${missing.join(", ")}`);
return { ...(meta as unknown as DocMeta), path, body };
});
}

parseFrontMatter() için YAML kütüphanesi yok; key: value satırları yetiyor:

src/steps/1-load.ts
/**
* What it does: splits a file into its labels on top (id, edition, access…) and its text.
*
* Split a file into its front matter (the `---` block on top) and its body.
* Only `key: value` lines are supported — enough for our metadata, no YAML library needed.
* @param raw the whole file, line endings already normalised to \n
* @example parseFrontMatter('---\nid: hr-leave\nedition: "2025"\n---\nbody').meta.edition // "2025"
*/
export function parseFrontMatter(raw: string): { meta: Record<string, string>; body: string } {
const m = raw.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
if (!m) return { meta: {}, body: raw };
const meta: Record<string, string> = {};
for (const l of m[1]!.split("\n")) {
const kv = l.match(/^(\w+):\s*(.*)$/);
if (kv) meta[kv[1]!] = kv[2]!.trim().replace(/^["'](.*)["']$/, "$1");
}
return { meta, body: m[2]! };
}

Dönen kayıt bir Doc: src/lib/types.ts içinde bir TypeScript interface’i, class değil. DocMeta üstteki bloğun alanları, body dosyanın geri kalanı:

src/lib/types.ts
/** Step 1 output: one file from corpus/<edition>/, front matter parsed, body untouched. */
export interface Doc extends DocMeta {
path: string;
body: string;
}

writeStep() 8 kaydı data/1-docs.json içindeki data dizisine yazar. Üstteki hash ve inputHash alanları sayesinde 2-clean.ts eski bir dosyayla çalışmaz.

npm run step -- 1
$ npm run step -- 1
━━ STEP 1/8 · LOAD
IN corpus/2025/
WHAT read every file · front matter → metadata (id, edition, department, access, lang) · body kept as-is
OUT 8 documents · 42986 characters
announcement-meal-card en all 1277 chars 37 lines
benefits en all 4875 chars 124 lines
code-of-conduct en all 5371 chars 133 lines
hr-leave en all 7776 chars 198 lines
hr-salary-bands en hr-only 4785 chars 125 lines
hr-working-hours en all 5941 chars 147 lines
it-security tr all 6934 chars 163 lines
travel-expenses en all 6027 chars 151 lines
TIME 6 ms → data/1-docs.json

2-clean.ts · data/1-docs.json → data/2-clean.json

Bölüm başlığı “2-clean.ts · data/1-docs.json → data/2-clean.json”

src/steps/2-clean.ts içindeki run(), readStep() ile data/1-docs.json dosyasını okur. Her kayıt şu satırdan geçer:

src/steps/2-clean.ts
const docs: CleanDoc[] = input.data.map(({ path: _path, body, ...meta }) => ({ ...meta, ...cleanText(body) }));

path düşer (_path adıyla alınıp kullanılmaz), metadata (...meta) olduğu gibi kopyalanır, cleanText(body) temiz text alanını ve atılan satır sayısını (noiseLines) ekler. Kaydın şekli CleanDoc, types.ts içinde Doc’un hemen altında.

Birleştirmenin sınırı: başlık, tablo satırı, boş satır ve liste maddesi hiçbir satıra eklenmez.

src/steps/2-clean.ts
/**
* What it does: checks if a line is a heading, a table row, or a blank line.
*
* Headings, table rows and blank lines are complete on their own — nothing is joined onto them.
*/
const closed = (l: string) => l.startsWith("#") || l.startsWith("|") || l.trim() === "";
/**
* What it does: checks if a line must start fresh, not be joined to the line before.
*
* Headings, table rows, blank lines and list items always start a new line.
*/
const opens = (l: string) => closed(l) || /^\s*([-*]|\d+\.)\s/.test(l);
/**
* What it does: drops noise lines, then joins lines that the page width broke apart.
*
* Remove noise lines, then re-join prose lines that the page width broke apart.
* @param body a document body from step 1
* @returns the clean text and how many noise lines were dropped
*/
export function cleanText(body: string, rules: RegExp[] = NOISE): { text: string; noiseLines: number } {
const lines = body.split("\n");
const kept = lines.filter((l) => !isNoise(l, rules));
const out: string[] = [];
for (const l of kept) {
const prev = out[out.length - 1];
if (prev !== undefined && !closed(prev) && !opens(l)) out[out.length - 1] = `${prev} ${l.trim()}`;
else out.push(l);
}
const text = out.join("\n").replace(/\n{3,}/g, "\n\n").trim();
return { text, noiseLines: lines.length - kept.length };
}

Gürültü kuralları bir listede; // default işaretli satırlar varsayılan olarak açık. Boş turkishConfidential sizin işiniz:

src/steps/2-clean.ts
// ── YOUR TURN ───────────────────────────────────────────────────────────────────────────────────
// The Turkish document (it-security) repeats "GİZLİ – Yalnızca şirket içi kullanım içindir" under the page header.
// No rule removes it yet: find it in data/2-clean.json (copy the İ and the – from there). Write the rule the way
// the English confidentiality rule below is written. Check: npm run step -- 2 → it-security shows −12 noise lines, not −8.
export const turkishConfidential: RegExp | undefined = undefined;
// TOGGLE noise rules — comment one out, re-run step 2, and find what it used to remove
const NOISE: RegExp[] = [
/^Kraken Air \| .+ \| (Sürüm|Edition) \d{4}$/, // default — page header, repeated on every page
/^CONFIDENTIAL – .+$/, // default — English confidentiality line, under the page header
/^Sayfa \d+ \/ \d+$/, // default — Turkish page number
/^Page \d+ of \d+$/, // default — English page number
...(turkishConfidential ? [turkishConfidential] : []),
];
npm run step -- 2
$ npm run step -- 2
━━ STEP 2/8 · CLEAN
IN data/1-docs.json (8 documents, edition 2025)
WHAT drop noise lines (4 rules) · re-join lines the page width broke
OUT 1078 lines → 512 lines · 74 noise lines removed
announcement-meal-card − 3 noise lines
benefits − 9 noise lines
code-of-conduct − 9 noise lines
hr-leave −12 noise lines
hr-salary-bands − 9 noise lines
hr-working-hours −12 noise lines
it-security − 8 noise lines
travel-expenses −12 noise lines
before (hr-leave, first 6 lines):
│ Kraken Air | Annual Leave Policy | Edition 2025
│ CONFIDENTIAL – Internal use only
│
│ # Annual Leave Policy
│
│ ## 1. Purpose and Scope
after:
│ # Annual Leave Policy
│
│ ## 1. Purpose and Scope
TIME 6 ms → data/2-clean.json

Türkçe doküman (it-security) her sayfada GİZLİ – Yalnızca şirket içi kullanım içindir satırını taşıyor ve onu atan kural yok. Log’da it-security yanında −8 var: 4 sayfa başlığı ve 4 sayfa numarası gitti, 4 gizlilik satırı kaldı. Kalan gürültü kaybolmaz: B sayfasının bölme yöntemiyle it-security@2025#0 adlı bir chunk olur ve içinde doküman başlığıyla bu satırdan başka bir şey yoktur. Gerçek bir içeriği yok; ama her chunk gibi bir vektör alır ve depoda yer tutar. “Gürültü neden sorun?” adımındaki sorun, tek bir chunk’ta.

1 · Ham veriye bakın. corpus/2025/hr-leave.md dosyasını açın: --- bloğunu, sayfa başlığını ve Page 1 of 4 satırını bulun. Sonra npm run step -- 1 ve data/1-docs.json: her dokümanın access ve lang alanlarına bakın. hr-salary-bands dokümanını yalnızca İK’nın okuyabildiğini hangi alan gösteriyor?

2 · Bir kuralı kapatın. src/steps/2-clean.ts içinde NOISE listesinin ilk satırını (sayfa başlığı kuralı) başına // koyarak kapatın ve npm run step -- 2 çalıştırın. Log’da hr-leave satırı −12 yerine −8 gürültü satırı göstermeli. data/2-clean.json içinde Edition 2025 arayın: başlık metne geri döndü. Satırı geri açıp npm run step -- 2 komutunu yeniden çalıştırın.

3 · Benzerini yazın. Aynı dosyada turkishConfidential boş. İngilizce satırın kuralı /^CONFIDENTIAL – .+$/ şeklinde. Türkçe gizlilik satırı için aynı türden bir kural yazın. İpucu: tire uzun tire (–), İ harfi noktalı; satırı dosyadan kopyalayın, GIZLI yazmayın. Sonra:

  • npm run step -- 2 → it-security satırı −8 yerine −12 gürültü satırı göstermeli.
  • data/2-clean.json içinde GİZLİ arayın: satır artık yok.
  • İsterseniz npm run step -- 3 ve data/3-chunks.json: it-security@2025#0 artık yok.

Kural yazılınca 2-clean.ts, 74 yerine 78 gürültü satırı atar ve 1078 satır 504 satıra iner. Sonraki sayfaların log’ları kural yazılmadan alındı: log 60 chunk (iki sürümle 120 kayıt) gösterir, sizde 59 chunk (118 kayıt) olur; bu fark beklenen.

Geride kaldıysanız: npm run catchup -- 2.

edition neden tırnak içinde yazılmış?

Filtre metinleri karşılaştırır: "2025" metni ile 2025 sayısı eşleşmez. Tırnak, bir YAML okuyucusunun değeri sayıya çevirmesini engeller; bizim küçük okuyucu her değeri zaten metin olarak alıyor.

Bir temizlik kuralı yanlışlıkla içerik silerse?

Bu yüzden kurallar satırın başına ve sonuna bağlı (^ … $): yalnızca bütün satır kalıba uyarsa silinir. turkishConfidential kuralınızda da bu bağları koruyun: kural, “Gizli veriler şifrelenmelidir.” cümlesini silmemeli.

Kirli metinle iyi arama olmaz.