Embedding ve vektör veritabanı üzerine kurulu bir "benzer kayıtları getir" özelliği geliştirdiğinizde her değişiklik — yeni bir model, farklı bir chunking stratejisi, ayarlanmış bir threshold — aynı soruyu beraberinde getirir: bu değişiklik retrieval’ı gerçekten daha mı iyi yaptı, yoksa sadece farklı mı yaptı? Ölçülebilir bir referansınız yoksa, sonuç sezgilerle ilerlemek olur.
Bu yazıda, Spring Boot uygulamamda golden pair adı verilen, etiketlenmiş örneklerden oluşan küçük ama düzgün kurulmuş bir değerlendirme (evaluation) altyapısının nasıl inşa edildiğini anlatacağım. Bu altyapı, Recall@K ve Mean Reciprocal Rank (MRR) gibi sektörde yaygın metrikleri hesaplıyor ve yetkili kullanıcılara açtığım bir admin endpoint üzerinden, üretim ortamına yeni bir değişiklik geçirilmeden önce her zaman yeniden çalıştırılabiliyor.
1. Sezgilerle Ayar Yapmanın Sorunu #
Bir destek portalında, agent’lar gelen yeni bir ticket’a benzer geçmiş ticket’lar önerildiğinde çok daha hızlı çözüme ulaşıyorlar. "Benzerini getir" özelliği temelde üç parçadan oluşuyor:
- ticket’ı düz metne dönüştüren bir content builder,
- bu metni vektöre çeviren bir embedding modeli,
- en yakın komşuları (top-K) döndüren bir vektör veritabanı.
Bu pipeline’daki her parça aslında bir ayar düğmesi. Embedding modelini değiştirin? Vektör boyutu, semantik komşuluk ve benzerlik skorları değişir. Ticket’ın metne dönüştürülme şeklini değiştirin? Modele giren sinyaller değişir. Benzerlik eşiğini değiştirin? Recall/precision dengesi kayar.
Bir değişikliğin gerçekten iyileştirme olup olmadığını anlamanın tek dürüst yolu, "iyi" olanı somut örneklerle tanımlamak ve onunla karşılaştırmaktan geçiyor.
2. Doğruluğun Kaynağı: Golden Pair’ler #
Bir golden pair, etiketlenmiş bir sorgudur: "Bu girdiye karşılık, sistem şu ticket’ları döndürmeli." Etiketler, alanı bilen kişiler tarafından üretilir.
Tek bir pair’i Java record ile şu şekilde modelliyorum:
@JsonInclude(JsonInclude.Include.NON_NULL)
public record EvalGoldenPair(
String id,
String queryText,
Long queryTicketId,
List<Long> expectedSimilarTicketIds,
String note
) {
public EvalGoldenPair {
if ((queryText == null || queryText.isBlank()) && queryTicketId == null) {
throw new IllegalArgumentException(
"EvalGoldenPair " + id + " must define either queryText or queryTicketId");
}
if (expectedSimilarTicketIds == null || expectedSimilarTicketIds.isEmpty()) {
throw new IllegalArgumentException(
"EvalGoldenPair " + id + " must define at least one expectedSimilarTicketId");
}
}
}
Burada altını çizmek istediğim birkaç tasarım kararı var:
queryTextveyaqueryTicketId. Bazı pair’lerde sorgu, yeni bir ticket’ı simüle eden, elle yazılmış bir metindir. Bazılarında ise zaten çözülmüş mevcut bir ticket’ı, kalan korpus üzerinde sorgu olarak kullanırız — bu leave-one-out tarzı bir değerlendirmedir. Compact constructor, bu iki yoldan en az birinin kullanılmasını zorunlu kılıyor.expectedSimilarTicketIdstek bir id değil, bir listedir. Birden fazla geçmiş ticket aynı anda alakalı olabilir. Listenin ilk elemanı en alakalı olan; sonrakiler de geçerli alakalı sonuçlardır.
note.: Etiketleyenin "bu pair neden burada?" sorusuna yazılı cevap verdiği serbest metin alanı. İlerde bir regression olduğunda, "ileride bu kodu okuyacak olan ben" çok minnettar olacaktır.
Dataset wrapper’ı ise basit bir liste tutucu:
public record EvalDataset(
String name,
String description,
List<EvalGoldenPair> pairs
) {}
Listeyi doğrudan List<EvalGoldenPair> olarak değil de bir record içinde tutmak, ileride mevcut dosyaları kırmadan datasetVersion veya tags gibi alanlar eklenebilmesini sağlıyor.
3. Dataset’i Classpath’ten Yükleme #
Dataset’ler classpath’te JSON formatında duruyor. Repo’da golden-pairs.example.json adıyla çalıştırılabilir bir başlangıç dosyası tutuyorum; gerçek dataset (müşteri verisi içerebileceği için hassas olabilir) golden-pairs.json adıyla aynı dizine kopyalanıyor ve .gitignorea ekleniyor.
@Slf4j
@Component
@RequiredArgsConstructor
public class EvalDatasetLoader {
static final String DEFAULT_DATASET_PATH = "classpath:ai-eval/golden-pairs.json";
static final String EXAMPLE_DATASET_PATH = "classpath:ai-eval/golden-pairs.example.json";
private final ResourceLoader resourceLoader;
private final ObjectMapper objectMapper;
public EvalDataset load() {
return load(DEFAULT_DATASET_PATH);
}
public EvalDataset load(String path) {
Resource resource = resourceLoader.getResource(path);
if (!resource.exists()) {
log.warn("Eval dataset not found at {} — falling back to example dataset at {}",
path, EXAMPLE_DATASET_PATH);
resource = resourceLoader.getResource(EXAMPLE_DATASET_PATH);
if (!resource.exists()) {
throw new IllegalStateException(
"No eval dataset found at " + path + " or " + EXAMPLE_DATASET_PATH);
}
}
try (InputStream in = resource.getInputStream()) {
EvalDataset dataset = objectMapper.readValue(in, EvalDataset.class);
List<EvalGoldenPair> pairs = dataset.pairs();
if (pairs == null || pairs.isEmpty()) {
throw new IllegalStateException("Eval dataset at " + path + " contains no pairs");
}
log.info("Loaded eval dataset '{}' with {} pairs from {}",
dataset.name(), pairs.size(), path);
return dataset;
} catch (IOException ex) {
throw new IllegalStateException("Failed to read eval dataset from " + path, ex);
}
}
}
path parametre olarak alınıyor; bu sayede ekip birden fazla dataset (örneğin ürün başına, müşteri başına ya da kalite seviyesine göre) tutup admin endpoint üzerinden hangisinin çalıştırılacağını seçebiliyor.
4. Metrikler: Recall@K ve MRR #
Değerlendirmeyi iki temel metrik yönlendiriyor:
- Recall@K — Her pair için, beklenen id’lerden en az biri top-K sonuçları arasında geldi mi? Tüm pair’ler üzerinden ortalama alınır. Ben Recall@1, Recall@3 ve Recall@5 raporluyorum.
- Mean Reciprocal Rank (MRR) — Her pair için
1 / (ilk alakalı sonucun sırası)hesaplanır; sıra 1’den başlar; eğer beklenen id’lerden hiçbiri top-K’ya girmediyse o pair0katkıda bulunur. Tüm pair’ler üzerinden ortalama alınır. MRR, sistemi sadece doğru cevabı listeye sokması için değil, üst sıralara koyması için ödüllendirir.
Bunlara ek olarak bir de sanity-check niteliğinde "ortalama top-1 cosine similarity skoru" tutuyorum; ama bu bir kalite metriği değildir: kendinden emin bir biçimde yanlış dönen bir sonucun skoru, daha temkinli bir doğru sonucun skorundan yüksek olabilir. Bu metrik daha çok, kötü bir konfigürasyon değişikliğinden sonra skorların topluca çökmesi gibi durumları yakalamak için kullanılıyor.
5. Değerlendirmenin Çalıştırılması #
EmbeddingEvaluator, her pair için canlı retrieval pipeline’ını bir kez sorguluyor; pair bazlı metrikleri hesaplıyor ve sonuçları rapora topluyor:
@Slf4j
@Service
@RequiredArgsConstructor
public class EmbeddingEvaluator {
static final int TOP_K = 5;
private final TicketEmbeddingService embeddingService;
private final TicketRepository ticketRepository;
private final TicketContentBuilder contentBuilder;
public EvalReport evaluate(EvalDataset dataset) {
long started = System.currentTimeMillis();
List<EvalReport.PerPairResult> perPair = new ArrayList<>(dataset.pairs().size());
int hitsAt1 = 0, hitsAt3 = 0, hitsAt5 = 0;
double mrrSum = 0.0;
double topScoreSum = 0.0;
int topScoreCount = 0;
for (EvalGoldenPair pair : dataset.pairs()) {
EvalReport.PerPairResult result = evaluatePair(pair);
perPair.add(result);
if (result.hitAt1()) hitsAt1++;
if (result.hitAt3()) hitsAt3++;
if (result.hitAt5()) hitsAt5++;
if (result.firstRelevantRank() > 0) {
mrrSum += 1.0 / result.firstRelevantRank();
}
if (!result.retrievedScores().isEmpty()) {
topScoreSum += result.retrievedScores().getFirst();
topScoreCount++;
}
}
int total = dataset.pairs().size();
double averageTopScore = topScoreCount == 0 ? 0.0 : topScoreSum / topScoreCount;
long duration = System.currentTimeMillis() - started;
return new EvalReport(
total,
(double) hitsAt1 / total,
(double) hitsAt3 / total,
(double) hitsAt5 / total,
mrrSum / total,
averageTopScore,
duration,
perPair
);
}
private EvalReport.PerPairResult evaluatePair(EvalGoldenPair pair) {
String queryText = resolveQueryText(pair);
List<TicketSimilarityResult> hits = embeddingService.findSimilar(queryText, TOP_K, 0.0);
List<Long> retrievedIds = new ArrayList<>(hits.size());
List<Double> retrievedScores = new ArrayList<>(hits.size());
for (TicketSimilarityResult hit : hits) {
// Sorgunun kendi ticket'ını atlıyoruz — leave-one-out tarzı bir
// değerlendirmede bu ticket her zaman 1. sırada gelirdi ve
// retrieval kalitesini olduğundan iyi gösterirdi.
if (pair.queryTicketId() != null && hit.ticketId() == pair.queryTicketId()) {
continue;
}
retrievedIds.add(hit.ticketId());
retrievedScores.add(hit.score());
}
int firstRelevantRank = firstRelevantRank(retrievedIds, pair.expectedSimilarTicketIds());
return new EvalReport.PerPairResult(
pair.id(),
pair.expectedSimilarTicketIds(),
retrievedIds,
retrievedScores,
firstRelevantRank,
firstRelevantRank == 1,
firstRelevantRank > 0 && firstRelevantRank <= 3,
firstRelevantRank > 0 && firstRelevantRank <= 5
);
}
private static int firstRelevantRank(List<Long> retrieved, List<Long> expected) {
for (int rank = 1; rank <= retrieved.size(); rank++) {
if (expected.contains(retrieved.get(rank - 1))) {
return rank;
}
}
return 0;
}
}
Burada altını çizmek istediğim üç ince karar var:
TOP_Kconfig’ten okunmuyor, sabit 5. Üretimde top-K her ortam için ayrı yapılandırılabiliyor. Ama eval de aynı ayarı kullansaydı, her config değişikliğiyle birlikte raporlanan sayılar sessizce değişirdi — ve modelin gerçekten iyileştiğini mi, yoksa sadece daha çok aday getirdiğinizi mi göremezdiniz. Sabit bir eval penceresi, çalışmaları karşılaştırılabilir kılıyor.findSimilara verilen minimum benzerlik threshold’u0.0. Recall zaten "sonuç yeterince iyi miydi?" sorusunu kapsıyor. Skor üzerinden ayrıca filtre uygulamak iki ayrı soruyu birbirine karıştırırdı: "Doğru ticket bulunabilir miydi?" ve "Skor bir eşik üzerinde mi?".- Leave-one-out atlaması. Sorgu zaten mevcut bir ticket’sa, vektör veritabanı bu ticket’ı keyifle 1. sıraya yazardı. Sırayı hesaplamadan önce onu listeden çıkarmak, eval’in olduğundan iyi görünmesini engelliyor.
6. Rapor #
EvalReport toplu sonuçları taşıyor; ama her pair’in detayı da hata ayıklayabilelim diye saklanıyor:
public record EvalReport(
int totalPairs,
double recallAt1,
double recallAt3,
double recallAt5,
double meanReciprocalRank,
double averageTopScore,
long durationMillis,
List<PerPairResult> perPair
) {
public record PerPairResult(
String id,
List<Long> expectedSimilarTicketIds,
List<Long> retrievedTicketIds,
List<Double> retrievedScores,
int firstRelevantRank,
boolean hitAt1,
boolean hitAt3,
boolean hitAt5
) {}
}
Üst seviye sayılar, değişikliğin iyi olup olmadığını söyler. Pair bazlı liste ise hangi sorguların gerilediğini söyler — ki bir sonraki iterasyonu mümkün kılan asıl şey budur. "Recall@5, 0,78’den 0,74’e düştü" bir problem ifadesidir; "Recall@5 0,78’den 0,74’e düştü ve şu üç pair durumu değiştirdi" ise üzerinde çalışılabilecek bir ipucudur.
7. Değerlendirmenin İstendiğinde Çalıştırılması #
Son olarak, eval’i istediğimiz zaman tekrar tetikleyebileceğimiz, sadece admin’e açık bir HTTP endpoint:
@RestController
@RequestMapping(API_PREFIX + "/ai/admin/evaluation")
@RequiredArgsConstructor
public class EvaluationAdminController {
private final EvalDatasetLoader datasetLoader;
private final EmbeddingEvaluator evaluator;
@PostMapping("/run")
@PreAuthorize(Role.HAS_ADMIN)
public EvalReport run(@RequestParam(required = false) String datasetPath) {
EvalDataset dataset = datasetPath == null || datasetPath.isBlank()
? datasetLoader.load()
: datasetLoader.load(datasetPath);
return evaluator.evaluate(dataset);
}
}
Beklenen kullanım akışı oldukça basit:
- Bir retrieval parametresini, modeli veya prompt’u değiştirin.
POST /api/ai/admin/evaluation/runendpoint’ine istek atın.- Raporu önceki referansla karşılaştırın.
- Sayılar gerçekten iyileştiyse (ya da önemli olan metriklerde sabit kaldıysa) değişikliği üretime alın.
Aynı evaluator’ı bir CI işine bağlayıp, Recall@5 belli bir tabanın altına düştüğünde build’i başarısız hale getirmek de mümkün — bu yapı, kod henüz üretime gitmeden devreye giren bir regression koruması haline gelir.
Sonuç #
Değerlendirme altyapısı olmadan bir AI özelliği geliştirmek, test suite’i olmayan bir refactoring sürecine benziyor: her değişiklik ilerleme gibi hissettiriyor, ama kanıtlamak imkânsız. Bu yazıda yürüdüğüm yapı bilinçli olarak küçük tutuldu — üç record, iki servis, bir controller ve bir JSON dosyası — yine de "bu daha iyi mi?" sorusunu, çalışmalar arasında karşılaştırılabilir bir sayıya çeviriyor.
Bu yatırım, birisi "yeni embedding modelinin gerçekten daha iyi olduğundan emin miyiz?" diye sorduğu ilk anda kendini geri ödüyor: cevap artık "Recall@5, 0,71’den 0,83’e çıktı, durumu değiştiren dört pair de şunlar" oluyor. Sezgilerden sayılara.
Bu minimal versiyonu büyütmek istediğinizde doğal sonraki adımlar şunlar olabilir: dataset bazında metrikleri zaman içinde takip etmek, false-positive’in canınızı yaktığı senaryolarda precision tarzı metrikler eklemek, ya da retrieval yerine doğrudan LLM’in cevabını puanlamak (LLM-as-judge). Ama ilk gün bunların hiçbirine ihtiyacınız yok. Küçük bir dataset ve Recall@K, içgüdüyle değil veriyle yön bulmaya başlamak için yeterli.
Sorularınız için ya da kendi retrieval pipeline’ınız için benzer bir altyapı kurmayı tartışmak isterseniz benimle iletişime geçebilirsiniz.