İçeriğe geç

D · Vector DB

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?”
→ “An employee with 7 years of service gets 22 working days of annual leave [1].” ✓
chroma store · chunker section · rerank on — cevap kara kutudakiyle aynı

Şimdiye kadar “veritabanı” bir JSON dosyasıydı ve her soruda soru vektörü 60 vektörün hepsiyle tek tek karşılaştırıldı. 60 için sorun değil. Bir milyon chunk’ta her soru bir milyon karşılaştırma demek.

aramak içinsoru vektörüne en yakınlarmodele vermek içinvektör metne geri dönmezfiltre içinedition, access …idhr-leave@2025#3embedding[ … 1024 sayı … ]document[Annual Leave Policy › 3. Annual Paid Leave]Part-time staff get the same number … | 7 | 22 | …metadatadocId: hr-leave · edition: 2025 · title: Annual Leave Policyaccess: all · lang: en · sections: 3
JSON · hepsine bakChroma · komşulara bakgirişbrute force: 60 vektör, 60 karşılaştırma · kesinHNSW: birkaç atlama · yaklaşıkşematikilk 5 id: aynı ✓
  1. Bir kayıtta ne var?

    5-store.ts her chunk’ı Chroma’ya bir kayıt olarak yazar: id, embedding (vektör), document (metin) ve metadata.

    podman compose ps → healthy olmalı. Değilse podman compose up -d.
  2. Vektör: aramak için

    Arama vektörle yapılır: soru vektörüne en yakın kayıtları bulur. Vektörler hazır gelir; onları 4-embed.ts hesaplar.

  3. Metin: modele vermek için

    1024 sayıdan cümle geri çıkmaz. Arama vektörle yapılır, prompt’a metin girer. Bu yüzden kayıt ikisini birlikte taşır: vektör bulur, metin anlatır.

  4. Metadata: filtre için

    edition, access, docId …: A sayfasında okuduğumuz etiketler. 6-retrieve.ts “yalnızca 2025, yalnızca herkese açık” der; Chroma bunu aramanın içinde uygular.

  5. Brute force: hepsine bak

    JSON deposu her soruda bütün vektörlerle cosine hesaplar ve sıralar. Sonuç kesin, ama iş chunk sayısıyla birlikte büyür. Bu yöntemin adı brute force.

    npm run step -- 5 --store json, sonra data/store.json dosyasını açın.
  6. Vector DB: komşulara bak

    Chroma HNSW adlı bir grafik indeksi kurar ve her vektörü komşularına bağlar. Arama bir noktadan başlar, soruya doğru komşudan komşuya atlar ve grafiğin yalnızca küçük bir kısmını gezer.

    npm run step -- 5 çalıştırın.
  7. Bedeli: yaklaşık

    Bu aramanın adı ANN: approximate nearest neighbor. En yakınları çok büyük olasılıkla bulur ama garanti etmez. Hız için kesinlikten biraz vazgeçilir; indeksin ayarları bu dengeyi belirler.

    Bruno’da local ortamını seçin, 01-chroma klasöründeki istekleri kutudaki sırayla gönderin; collections içinde hnsw ayarlarını bulun.
  8. Bizde sıralama değişti mi?

    Aynı soru, iki depo: JSON’un ve Chroma’nın ilk 5 id’si aynı, aynı sırada. 60 kayıtta yaklaşıklık görünmüyor. Hız kazancı da henüz görünmüyor; ikisi de milyonlarca kayıtta ortaya çıkar.

    podman compose down, sonra podman compose up -d; Bruno’da önce collections, sonra count.
    npm run question: HOW satırında artık chroma store yazıyor. Cevap yine 22 mi?

    (Veritabanını durdurup yeniden başlattık. Kayıtlar hâlâ yerinde. Veri bellekte değil, diskte, bir volume’da duruyor. Yani yeniden başlatmak, her şeyi yeniden embed etmek demek değil.)

5-store.ts, Chroma’ya yazdıktan sonra günün sorusunu JSON deposuna da soruyor ve iki deponun ilk 5’ini yan yana koyuyor. TIME satırları adımın tamamını ölçer, yazma da dahil. Bir arama yarışı değil.

npm run step -- 5 --store json
$ npm run step -- 5 --store json
━━ STEP 5/8 · STORE
IN data/4-vectors.json (60 vectors, edition 2025)
WHAT replace edition 2025 in data/store.json (no index: every search visits every vector)
OUT 60 records added · 60 in the store · editions: 2025
TIME 29 ms → data/5-store.json
npm run step -- 5
$ npm run step -- 5
━━ STEP 5/8 · STORE
IN data/4-vectors.json (60 vectors, edition 2025)
WHAT replace edition 2025 in Chroma collection "kraken-policies" (HNSW index, cosine)
OUT 60 records added · 60 in the store · editions: 2025
the day's question, top-5 ids:
JSON (brute force) hr-leave@2025#3 hr-leave@2025#2 hr-leave@2025#7 hr-leave@2025#6 hr-leave@2025#4
chroma hr-leave@2025#3 hr-leave@2025#2 hr-leave@2025#7 hr-leave@2025#6 hr-leave@2025#4
identical ✓ — the database changes the scale, not the ranking
TIME 138 ms → data/5-store.json

Brute force’un kendisi, JSON deposunun arama fonksiyonu: filtrele, her kayıtla cosine hesapla, sırala, ilk k’yı al.

src/lib/store-json.ts
/** What it does: filters the records, scores every one by cosine, and returns the k best. */
async query(vector: number[], k: number, filter?: Filter): Promise<Hit[]> {
return (await this.all())
.filter((r) => passes(r, filter))
.map(({ vector: v, ...chunk }) => ({ chunk, score: cosine(vector, v) }))
.sort((a, b) => b.score - a.score)
.slice(0, k);
}

Chroma tarafında aynı iş bir sorgu. Önce koleksiyon: Chroma’ya vektör üretmemesini (embeddingFunction: null) ve cosine kullanmasını söylüyoruz.

src/lib/chroma.ts
/** What it does: opens or creates our Chroma collection, and fails clearly if Chroma is down. */
private async collection(): Promise<Collection> {
if (!(await heartbeat())) {
throw new Error(
`Chroma is not reachable at ${config.chromaUrl}. Start it: podman compose up -d (or use --store json)`,
);
}
// cosine space: Chroma returns distance = 1 − cosine similarity
/**
* HNSW settings: we set only `space`. Chroma fills in the rest with its defaults
* (see them in Bruno: collections → configuration_json → hnsw).
* For a more accurate search, add more, for example { space: "cosine", ef_search: 200, max_neighbors: 32 }.
* Higher means closer to exact, but slower. Checking every vector is brute force.
* max_neighbors only applies when the collection is created: run npm run catchup -- 5.
*/
return chroma.getOrCreateCollection({
name: this.name,
embeddingFunction: null,
configuration: { hnsw: { space: "cosine" } },
});
}

Kaydın dört parçası tek çağrıda gidiyor: ids, embeddings, documents, metadatas. Metadata değerleri düz metin ya da sayı olmak zorunda; bölüm listesi bu yüzden "2,3" gibi bir metne dönüşüyor.

src/lib/chroma.ts
/** What it does: deletes one edition's old chunks, then adds the new ones in batches of 100. */
async replaceEdition(edition: string, records: VectorChunk[]): Promise<void> {
const col = await this.collection();
await col.delete({ where: { edition } });
for (let i = 0; i < records.length; i += 100) {
const slice = records.slice(i, i + 100);
await col.add({
ids: slice.map((r) => r.id),
embeddings: slice.map((r) => r.vector),
documents: slice.map((r) => r.text),
// metadata values must be plain strings/numbers — the section list becomes "2,3"
metadatas: slice.map((r) => ({
docId: r.docId,
edition: r.edition,
title: r.title,
access: r.access,
lang: r.lang,
sections: r.sections.join(","),
})),
});
}
}
  • Ekle / güncelle. Aynı id’ye yazmak (upsert) eski kaydın yerini alır. Bizde replaceEdition önce o sürümün kayıtlarını siler, sonra yenilerini ekler; 5-store.ts dosyasını ikinci kez çalıştırmak çift kayıt üretmez.
  • Sil. where koşuluyla: bizde { edition }.
  • Filtrele. Yine where ile; toWhere() filtremizi Chroma’nın diline çeviriyor (E sayfası).
  • Sakla. Container durunca veri kaybolmaz: compose.yaml onu bir volume’a yazıyor.

Günün başında çalıştırdığımız komut bu dosyayı okuyor:

compose.yaml
# One container: the vector database. Ollama is NOT here — it runs on your machine, where the models
# are already pulled and the GPU is.
#
# podman compose up -d # start Chroma in the background (http://localhost:8000)
# podman compose ps # is it healthy?
# podman compose down # stop it; the data stays in the volume
# podman compose down -v # stop it and delete the stored vectors
#
# No compose? The same thing in one line:
# podman run -d --name chroma -p 8000:8000 -v chroma-data:/data docker.io/chromadb/chroma:1.5.9
services:
chroma:
image: docker.io/chromadb/chroma:1.5.9
ports:
- "8000:8000"
volumes:
- chroma-data:/data
environment:
ANONYMIZED_TELEMETRY: "false"
healthcheck:
test: ["CMD", "/bin/bash", "-c", "exec 3<>/dev/tcp/127.0.0.1/8000 && echo -e 'GET /api/v2/heartbeat HTTP/1.0\\r\\n\\r\\n' >&3 && grep -q nanosecond <&3"]
interval: 5s
timeout: 3s
retries: 12
volumes:
chroma-data:
  • image: docker.io/chromadb/chroma:1.5.9 — sürüm sabit. Herkesin makinesinde aynı Chroma, aynı API.
  • ports: "8000:8000" — Chroma’nın HTTP API’si localhost:8000’de. Kodumuz da Bruno da buraya bağlanıyor.
  • volumes: chroma-data:/data — kayıtlar container’ın içinde değil, adlandırılmış bir volume’da. podman compose down veriyi korur; down -v siler.
  • ANONYMIZED_TELEMETRY: "false" — Chroma dışarıya kullanım istatistiği göndermez.
  • healthcheck — her 5 saniyede /api/v2/heartbeat adresini yoklar. podman compose ps “healthy” diyorsa Chroma hazır.

Bruno’daki 01-chroma klasörü, kodumuzun yaptığı HTTP çağrılarının aynısını elle yapıyor. Yalnızca bakıyor; hiçbir şey değiştirmiyor.

bruno/01-chroma/peek.bru
body:json {
{
"limit": 3,
"include": ["documents", "metadatas"]
}
}

1 · Chroma ayakta mı? podman compose ps → healthy. Değilse podman compose up -d.

2 · Önce JSON. npm run step -- 5 --store json, sonra data/store.json: her kayıt bir chunk ve 1024 sayılık vector alanı.

3 · Sonra Chroma. npm run step -- 5 → log’da “identical ✓”.

4 · Bruno ile içeri bakın (local ortamını seçin, 01-chroma klasörü, sırayla):

  • heartbeat → bir sayı dönüyorsa Chroma ayakta.
  • collections → kraken-policies; configuration_json içinde hnsw: space: cosine, max_neighbors ve ef_search. Altındaki uzun schema bloğunu geçin. Koleksiyonun id’sini de kaydeder; count bu id’yi kullanır.
  • count → 60 (A’daki Türkçe gizlilik kuralını yazdıysanız 59). Catchup ya da sıfırlamadan sonra id değişir; önce collections’ı yeniden gönderin.
  • peek at three records → ids, documents, metadatas. include listesine "embeddings" ekleyip tekrar gönderin: 1024 sayı da gelir.
  • filter on metadata → where içine { "access": "hr-only" } yazın. Maaş bantları geliyor. Bu isteği kim gönderebilmeli? (Güvenlik sayfası.)

5 · Veri kalıyor mu? podman compose down, sonra podman compose up -d ve Bruno’da önce collections, sonra count: kayıtlar volume’da, sayı değişmemeli.

6 · Günün sorusu. npm run question: HOW satırında artık chroma store yazıyor. Cevap, section chunk’lardaki gibi yine 22 mi? Deftere bir satır daha eklenir.

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

Chroma mı, pgvector mı, Qdrant mı?

Hepsi aynı üç şeyi saklar: vektör, metin, metadata. Farklar, onları nasıl işlettiğinizde: zaten Postgres kullanıyorsanız pgvector yeni bir sistem getirmez; ölçekte, ne kadar iyi filtrelediklerinde, hybrid aramada ve yönetilen servis seçeneklerinde ayrışırlar. Chroma’yı tek container ve Bruno’dan okunabilen HTTP API’si için seçtik.

Chroma embedding'i kendisi yapamaz mı?

Yapabilir; biz kapattık ve vektörleri kendimiz Ollama ile ürettik. Böylece hangi modelin kullanıldığı tek yerde yazıyor ve ingest ile soru aynı modelden geçiyor.

Chroma skor değil mesafe döndürüyor, değil mi?

Evet. Cosine uzayında mesafe = 1 − cosine benzerliği. Kodumuz score: 1 - distance ile geri çeviriyor; JSON ve Chroma skorları bu yüzden aynı ölçekte ve yan yana konabiliyor.

HNSW'nin ayarları neyi değiştirir?

Her düğümün kaç komşuya bağlandığı (max_neighbors) ve aramanın kaç aday gezdiği (ef_search): daha çok = daha isabetli ve daha yavaş. Bugün varsayılanları kullanıyoruz; farklı ayarları ölçmedik.

Embedding anlamı koordinata çevirir; vector DB o koordinatlarda hızlı arar.