| from pathlib import Path |
| import pandas as pd |
| |
| from sentence_transformers import SentenceTransformer |
| from langchain_huggingface import HuggingFaceEmbeddings |
| from langchain_ollama import ChatOllama |
| from langchain_core.messages import HumanMessage, SystemMessage |
| import chromadb |
| from langchain_chroma import Chroma |
| from sklearn.metrics.pairwise import cosine_similarity |
| from chroma.chatbot_retriever import ask |
| from tqdm import tqdm |
| import random |
| import json |
| import numpy as np |
| import re |
|
|
| model = HuggingFaceEmbeddings(model_name="multi-qa-mpnet-base-dot-v1") |
| client = chromadb.PersistentClient(path="chroma/chroma_db") |
| data_dir = Path("datasets") |
| llm = ChatOllama(model="llama3.2", temperature=0) |
|
|
| vectorstore = Chroma( |
| client = client, |
| collection_name = "bank_faq", |
| embedding_function = model |
| ) |
|
|
| retriever = vectorstore.as_retriever( |
| search_type = "similarity", |
| search_kwargs = {"k": 3} |
| ) |
| |
|
|
| |
| |
|
|
| |
| |
|
|
|
|
| def llm_judge(query, expected, predicted, llm): |
| prompt = f""" |
| You are evaluating a question answering system. |
| |
| Question: {query} |
| Expected Answer: {expected} |
| Predicted Answer: {predicted} |
| |
| IMPORTANT RULES: |
| - Focus on whether the MEANING and KEY INFORMATION is the same |
| - As long as most of the words matches with the expected answer, do not score as 0.0 |
| - Ignore differences in formatting (bullet points vs numbered list vs plain text) |
| - Ignore minor wording differences as long as the information is the same |
| - Mark as correct if the predicted answer covers all the key points in the expected answer |
| - Mark as incorrect ONLY if key information is missing or wrong |
| |
| Respond ONLY in this JSON format, no other text: |
| - "correct": True if the meaning of the response matches with the expected answer, otherwise False |
| - "score": 0.0 - 1.0 |
| - "reason": one-sentence explanation |
| """ |
| |
| response = llm.invoke(prompt) |
|
|
| |
| |
| |
| |
| |
| |
| |
| raw = response.content.strip() |
| raw = re.sub(r"^```(?:json)?\s*|\s*```$", "", raw).strip() |
| |
| |
|
|
| try: |
| parsed = json.loads(raw) |
| except json.JSONDecodeError as e: |
| print(f"JSON parse failed: {e}\nRaw text: {raw!r}") |
| parsed = {} |
|
|
| return { |
| "correct": False, |
| "score": 0.0, |
| "reason": "Predicted does not match with expected", |
| **parsed |
| } |
|
|
| |
| train_dataset = pd.read_csv(data_dir/"processed"/"bank_faq" /"train_faq.csv") |
| test_dataset = pd.read_csv(data_dir/"processed"/"bank_faq" /"test_faq.csv") |
|
|
| train_dataset = train_dataset.rename(columns={ |
| "Question": "query", |
| "Answer": "expected_answer" |
| }).to_dict(orient="records") |
|
|
| test_dataset = test_dataset.rename(columns={ |
| "Question": "query", |
| "Answer": "expected_answer" |
| }).to_dict(orient="records") |
|
|
| def retrieval_success_check(expected, retrieved_text, threshold=0.6): |
| |
| expected_vec = model.embed_query(expected) |
| retrieved_vec = model.embed_query(retrieved_text) |
|
|
| |
| score = cosine_similarity([expected_vec], [retrieved_vec])[0][0] |
|
|
| |
| return score >= threshold |
|
|
|
|
| def measure_hallucination(query: str, response: str, chunks: list[dict], llm) -> dict: |
| """ |
| Uses the LLM to check whether the response contains claims |
| not supported by the retrieved chunks. |
| |
| Returns: |
| - hallucinated: bool |
| - confidence: "high" | "medium" | "low" |
| - reason: short explanation |
| """ |
| context = "\n".join(c["text"] for c in chunks) if chunks else "No context retrieved." |
| |
| system = """ |
| You are a hallucination detector. Given a context, a query, and a response, |
| determine if the response contains information NOT supported by the context. |
| Return ONLY valid JSON: |
| - "hallucinated": true if the response contains unsupported claims, false otherwise |
| - "confidence": "high", "medium", or "low" |
| - "reason": one-sentence explanation |
| |
| Return ONLY the JSON object. No explanation. |
| """ |
| |
| result = llm.invoke([ |
| SystemMessage(content=system), |
| HumanMessage(content=( |
| f"Context:\n{context}\n\n" |
| f"Query: {query}\n\n" |
| f"Response: {response}" |
| )) |
| ]) |
| |
| raw = result.content.strip() |
| raw = re.sub(r"^```(?:json)?\s*|\s*```$", "", raw).strip() |
| |
| try: |
| parsed = json.loads(raw) |
| except json.JSONDecodeError: |
| parsed = {} |
| |
| return { |
| "hallucinated": True, |
| "confidence": "low", |
| "reason": "Hallucination check failed", |
| **parsed |
| } |
|
|
|
|
| FALLBACK_PHRASES = [ |
| "I don't have information on that.", |
| ] |
|
|
| def evaluate(dataset: list[dict], split: str = "", sample: int = None): |
| data = random.sample(dataset, sample) if sample else dataset |
|
|
| results = [] |
|
|
| correct = 0 |
| hallucinated = 0 |
| retrieval_failures = 0 |
|
|
| for item in tqdm(data, desc=f"Evaluating {split}", unit="q"): |
|
|
| query = item["query"] |
| expected = item["expected_answer"] |
|
|
| |
| predicted = ask(query=query, persist=False) |
|
|
| |
| docs_with_scores = vectorstore.similarity_search_with_score(query, k=3) |
| |
| |
| |
|
|
| retrieved_docs = [doc for doc, _ in docs_with_scores] |
| confidence_scores = [round(1 / (1 + score), 4) for _, score in docs_with_scores] |
| avg_confidence = round(sum(confidence_scores) / len(confidence_scores), 4) if confidence_scores else 0.0 |
|
|
| |
| retrieved_text = " ".join([doc.page_content for doc in retrieved_docs]).lower() |
| expected_in_context = expected.lower() in retrieved_text |
|
|
| retrieval_success = retrieval_success_check(expected, retrieved_text) |
|
|
| |
| |
| |
| is_fallback = any(phrase.lower() in predicted.lower() for phrase in FALLBACK_PHRASES) |
|
|
| if is_fallback: |
| judgement = { |
| "correct": False, |
| "score": 0.0, |
| "reason": "Model returned a fallback response" |
| } |
| hallucinated_flag = False |
| hallucination_detail = { |
| "hallucinated": False, |
| "confidence": "high", |
| "reason": "Model does not know the answer" |
| } |
| else: |
| judgement = llm_judge(query, expected, predicted, llm) |
|
|
| if judgement["correct"]: |
| hallucinated_flag = False |
| hallucination_detail = { |
| "hallucinated": False, |
| "confidence": "high", |
| "reason": "Response is correct" |
| } |
| correct += 1 |
| else: |
| chunks = [ |
| { |
| "text": doc.page_content, |
| "similarity_score": score |
| } |
| for doc, score in docs_with_scores |
| ] |
| hallucination_detail = measure_hallucination(query, predicted, chunks, llm) |
| hallucinated_flag = hallucination_detail["hallucinated"] |
| |
| |
|
|
| |
| |
|
|
| if hallucinated_flag: |
| hallucinated += 1 |
|
|
| if not retrieval_success: |
| retrieval_failures += 1 |
|
|
| results.append({ |
| "query": query, |
| "expected": expected, |
| "predicted": predicted, |
| "retrieval_success": bool(retrieval_success), |
| "correct": judgement["correct"], |
| "score": judgement["score"], |
| "reason": judgement["reason"], |
| "is_fallback": is_fallback, |
| "hallucination_detail": hallucination_detail, |
| "confidence_scores": confidence_scores, |
| "avg_confidence": avg_confidence |
| }) |
|
|
| metrics = { |
| "split": split, |
| "accuracy": correct / len(data), |
| "hallucination_rate": hallucinated / len(data), |
| "retrieval_failure_rate": retrieval_failures / len(data), |
| "samples": len(data) |
| } |
|
|
| return metrics, results |
|
|
| def save_results(metrics, results, filename="eval_results.json"): |
| output = { |
| "metrics": metrics, |
| "detailed_results": results |
| } |
|
|
| with open(filename, "w") as f: |
| json.dump(output, f, indent=2) |
|
|
| train_metrics, train_results = evaluate(train_dataset, "train", sample=200) |
| test_metrics, test_results = evaluate(test_dataset, "test", sample=20) |
|
|
| |
| |
| |
| |
|
|
| |
| |
| |
|
|
| |
| save_results(test_metrics, test_results, "test_eval.json") |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|