Use LLM-as-a-judge to test QA
This commit is contained in:
parent
095d532a7a
commit
2855dd7c13
5 changed files with 160 additions and 3 deletions
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
|
|
@ -3,6 +3,8 @@ from pathlib import Path
|
||||||
import pytest
|
import pytest
|
||||||
from datasets import Dataset, load_dataset, load_from_disk
|
from datasets import Dataset, load_dataset, load_from_disk
|
||||||
|
|
||||||
|
from .llm_judge import LLMJudge
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
def qa_corpus() -> Dataset:
|
def qa_corpus() -> Dataset:
|
||||||
|
|
@ -16,3 +18,8 @@ def qa_corpus() -> Dataset:
|
||||||
corpus = ds.filter(lambda doc: doc["document_topic"] == "News Stories")
|
corpus = ds.filter(lambda doc: doc["document_topic"] == "News Stories")
|
||||||
corpus.save_to_disk(ds_path)
|
corpus.save_to_disk(ds_path)
|
||||||
return corpus
|
return corpus
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def llm_judge() -> LLMJudge:
|
||||||
|
return LLMJudge()
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,16 @@
|
||||||
|
import asyncio
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from datasets import Dataset, load_dataset
|
from datasets import Dataset, load_dataset
|
||||||
|
from llm_judge import LLMJudge
|
||||||
from tqdm import tqdm
|
from tqdm import tqdm
|
||||||
|
|
||||||
from haiku.rag.client import HaikuRAG
|
from haiku.rag.client import HaikuRAG
|
||||||
|
from haiku.rag.qa.ollama import QA
|
||||||
|
|
||||||
|
|
||||||
async def populate_db():
|
async def populate_db():
|
||||||
if (Path(__file__).parent / "benchmark.sqlite").exists():
|
if (Path(__file__).parent / "data" / "benchmark.sqlite").exists():
|
||||||
print("Benchmark database already exists. Skipping creation.")
|
print("Benchmark database already exists. Skipping creation.")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
@ -61,6 +64,7 @@ async def run_match_benchmark():
|
||||||
recall_at_2 = correct_at_2 / total_queries
|
recall_at_2 = correct_at_2 / total_queries
|
||||||
recall_at_3 = correct_at_3 / total_queries
|
recall_at_3 = correct_at_3 / total_queries
|
||||||
|
|
||||||
|
print("\n=== Retrieval Benchmark Results ===")
|
||||||
print(f"Total queries: {total_queries}")
|
print(f"Total queries: {total_queries}")
|
||||||
print(f"Recall@1: {recall_at_1:.4f}")
|
print(f"Recall@1: {recall_at_1:.4f}")
|
||||||
print(f"Recall@2: {recall_at_2:.4f}")
|
print(f"Recall@2: {recall_at_2:.4f}")
|
||||||
|
|
@ -69,12 +73,48 @@ async def run_match_benchmark():
|
||||||
return {"recall@1": recall_at_1, "recall@2": recall_at_2, "recall@3": recall_at_3}
|
return {"recall@1": recall_at_1, "recall@2": recall_at_2, "recall@3": recall_at_3}
|
||||||
|
|
||||||
|
|
||||||
|
async def run_qa_benchmark():
|
||||||
|
"""Run QA benchmarking on the corpus."""
|
||||||
|
ds: Dataset = load_dataset("ServiceNow/repliqa")["repliqa_3"] # type: ignore
|
||||||
|
corpus = ds.filter(lambda doc: doc["document_topic"] == "News Stories")
|
||||||
|
|
||||||
|
judge = LLMJudge()
|
||||||
|
correct_answers = 0
|
||||||
|
total_questions = 0
|
||||||
|
|
||||||
|
async with HaikuRAG(Path(__file__).parent / "benchmark.sqlite") as rag:
|
||||||
|
qa = QA(rag)
|
||||||
|
|
||||||
|
for i, doc in enumerate(tqdm(corpus, desc="QA Benchmarking")):
|
||||||
|
question = doc["question"] # type: ignore
|
||||||
|
expected_answer = doc["answer"] # type: ignore
|
||||||
|
|
||||||
|
generated_answer = await qa.answer(question)
|
||||||
|
is_equivalent = await judge.judge_answers(
|
||||||
|
question, generated_answer, expected_answer
|
||||||
|
)
|
||||||
|
|
||||||
|
if is_equivalent:
|
||||||
|
correct_answers += 1
|
||||||
|
total_questions += 1
|
||||||
|
|
||||||
|
accuracy = correct_answers / total_questions if total_questions > 0 else 0
|
||||||
|
|
||||||
|
print("\n=== QA Benchmark Results ===")
|
||||||
|
print(f"Total questions: {total_questions}")
|
||||||
|
print(f"Correct answers: {correct_answers}")
|
||||||
|
print(f"QA Accuracy: {accuracy:.4f} ({accuracy * 100:.2f}%)")
|
||||||
|
|
||||||
|
|
||||||
async def main():
|
async def main():
|
||||||
await populate_db()
|
await populate_db()
|
||||||
|
|
||||||
|
print("Running retrieval benchmarks...")
|
||||||
await run_match_benchmark()
|
await run_match_benchmark()
|
||||||
|
|
||||||
|
print("\nRunning QA benchmarks...")
|
||||||
|
await run_qa_benchmark()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import asyncio
|
|
||||||
|
|
||||||
asyncio.run(main())
|
asyncio.run(main())
|
||||||
|
|
|
||||||
68
tests/llm_judge.py
Normal file
68
tests/llm_judge.py
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
import json
|
||||||
|
|
||||||
|
from ollama import AsyncClient
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from haiku.rag.config import Config
|
||||||
|
|
||||||
|
|
||||||
|
class LLMJudgeResponseSchema(BaseModel):
|
||||||
|
equivalent: bool
|
||||||
|
|
||||||
|
|
||||||
|
class LLMJudge:
|
||||||
|
"""LLM-as-judge for evaluating answer equivalence using Ollama."""
|
||||||
|
|
||||||
|
def __init__(self, model: str = "qwen3"):
|
||||||
|
self.model = model
|
||||||
|
self.client = AsyncClient(host=Config.OLLAMA_BASE_URL)
|
||||||
|
|
||||||
|
async def judge_answers(
|
||||||
|
self, question: str, answer: str, expected_answer: str
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Judge whether two answers are equivalent for a given question.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
question: The original question
|
||||||
|
answer: The generated answer to evaluate
|
||||||
|
expected_answer: The reference/expected answer
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with judgment result:
|
||||||
|
- equivalent: bool indicating if answers are equivalent
|
||||||
|
- explanation: str explaining the reasoning
|
||||||
|
- score: str rating from 1-5
|
||||||
|
"""
|
||||||
|
|
||||||
|
prompt = f"""
|
||||||
|
You are an expert judge evaluating the equivalence of two answers to the same question.
|
||||||
|
|
||||||
|
Question: {question}
|
||||||
|
|
||||||
|
Generated Answer: {answer}
|
||||||
|
|
||||||
|
Expected Answer: {expected_answer}
|
||||||
|
|
||||||
|
Your task is to determine if these two answers are equivalent in meaning and both correctly answer the question. Consider:
|
||||||
|
|
||||||
|
1. Do both answers provide the same answer?
|
||||||
|
2. Do both answers directly address the question asked?
|
||||||
|
3. Minor differences in wording or style are acceptable if the meaning of the answer is the same.
|
||||||
|
|
||||||
|
Be strict but fair in your evaluation. Focus on factual correctness and whether both answers would satisfy someone asking the question."""
|
||||||
|
|
||||||
|
response = await self.client.chat(
|
||||||
|
model=self.model,
|
||||||
|
messages=[{"role": "user", "content": prompt}],
|
||||||
|
format=LLMJudgeResponseSchema.model_json_schema(),
|
||||||
|
think=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
answer = response["message"]["content"].strip()
|
||||||
|
try:
|
||||||
|
res = json.loads(answer)
|
||||||
|
assert "equivalent" in res, "Response must contain 'equivalent' key"
|
||||||
|
return res["equivalent"]
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
assert False, "Response is not valid JSON"
|
||||||
42
tests/test_qa.py
Normal file
42
tests/test_qa.py
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from datasets import Dataset
|
||||||
|
|
||||||
|
from haiku.rag.client import HaikuRAG
|
||||||
|
from haiku.rag.qa.ollama import QA
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.append(str(Path(__file__).parent))
|
||||||
|
from llm_judge import LLMJudge
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_qa_with_dataset_question(qa_corpus: Dataset, llm_judge: "LLMJudge"):
|
||||||
|
"""Test QA with actual question from the dataset using LLM judge."""
|
||||||
|
client = HaikuRAG(":memory:")
|
||||||
|
qa = QA(client)
|
||||||
|
|
||||||
|
# Use the first document from the corpus
|
||||||
|
doc = qa_corpus[1]
|
||||||
|
|
||||||
|
# Add the document to database
|
||||||
|
await client.create_document(
|
||||||
|
content=doc["document_extracted"], uri=doc["document_id"]
|
||||||
|
)
|
||||||
|
|
||||||
|
question = doc["question"]
|
||||||
|
expected_answer = doc["answer"]
|
||||||
|
|
||||||
|
answer = await qa.answer(question)
|
||||||
|
# Use LLM judge to evaluate answer equivalence
|
||||||
|
is_equivalent = await llm_judge.judge_answers(question, answer, expected_answer)
|
||||||
|
|
||||||
|
assert isinstance(answer, str)
|
||||||
|
assert len(answer) > 0
|
||||||
|
assert is_equivalent, (
|
||||||
|
f"Generated answer not equivalent to expected answer.\nQuestion: {question}\nGenerated: {answer}\nExpected: {expected_answer}"
|
||||||
|
)
|
||||||
Loading…
Reference in a new issue