File size: 9,988 Bytes
e7a3876
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
"""

RAG Evaluation β€” lightweight implementation of RAGAS-style metrics.



Why not use the ragas library directly:

  ragas has unstable langchain_community dependencies that break frequently.

  This module implements the same four core metrics from first principles

  using only sentence-transformers and the Groq LLM we already have.



Metrics implemented:

  1. Faithfulness      β€” are answer claims supported by context?

  2. Answer Relevancy  β€” does the answer address the question?

  3. Context Precision β€” are retrieved chunks relevant to the question?

  4. Context Recall    β€” were all needed chunks retrieved?

"""

import numpy as np
from sentence_transformers import SentenceTransformer
from src.utils.logger import logger

# Use the same embedding model as the retrieval pipeline
_embedder = None


def _get_embedder():
    global _embedder
    if _embedder is None:
        _embedder = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")
    return _embedder


def _cosine_similarity(a, b):
    a, b = np.array(a), np.array(b)
    return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)))


# ------------------------------------------------------------
# Metric 1: Faithfulness
# "Is every claim in the answer supported by the retrieved context?"
# Score 0-1. Low score = hallucination.
# ------------------------------------------------------------

def faithfulness(answer: str, context_chunks: list[str], threshold: float = 0.5) -> dict:
    """

    Checks if each sentence in the answer has semantic support

    in at least one retrieved chunk.



    Args:

        answer:         The LLM-generated answer string.

        context_chunks: List of retrieved chunk texts.

        threshold:      Minimum cosine similarity to consider a sentence supported.



    Returns:

        {

            "score": float,           # 0-1, fraction of sentences supported

            "supported": int,         # number of supported sentences

            "total": int,             # total sentences checked

            "details": list[dict]     # per-sentence breakdown

        }



    Note: Production RAGAS uses an LLM to decompose answers into atomic claims.

    This implementation uses embedding similarity as a faster approximation.

    """
    model = _get_embedder()

    sentences = [s.strip() for s in answer.split(".") if s.strip()]
    if not sentences:
        return {"score": 0.0, "supported": 0, "total": 0, "details": []}

    chunk_embeddings = model.encode(context_chunks)
    details = []
    supported = 0

    for sentence in sentences:
        sentence_embedding = model.encode(sentence)
        max_similarity = max(
            _cosine_similarity(sentence_embedding, chunk_emb)
            for chunk_emb in chunk_embeddings
        )
        is_supported = max_similarity >= threshold
        if is_supported:
            supported += 1
        details.append({
            "sentence": sentence,
            "max_similarity": round(max_similarity, 4),
            "supported": is_supported,
        })

    score = supported / len(sentences)
    logger.debug(f"Faithfulness: {score:.2f} ({supported}/{len(sentences)} sentences supported)")
    return {
        "score": round(score, 4),
        "supported": supported,
        "total": len(sentences),
        "details": details,
    }


# ------------------------------------------------------------
# Metric 2: Answer Relevancy
# "Does the answer actually address the question asked?"
# Score 0-1. Low score = answer is off-topic or evasive.
# ------------------------------------------------------------

def answer_relevancy(question: str, answer: str) -> dict:
    """

    Measures semantic similarity between the question and answer.

    High similarity = answer directly addresses the question.



    Note: Production RAGAS generates multiple questions from the answer

    and measures how well they reconstruct the original question.

    This is a simpler direct similarity approximation.

    """
    model = _get_embedder()

    question_embedding = model.encode(question)
    answer_embedding = model.encode(answer)
    score = _cosine_similarity(question_embedding, answer_embedding)

    # Clamp to [0, 1] β€” cosine can be slightly negative
    score = max(0.0, min(1.0, score))

    logger.debug(f"Answer relevancy: {score:.2f}")
    return {
        "score": round(score, 4),
        "question": question,
        "answer_preview": answer[:100] + "..." if len(answer) > 100 else answer,
    }


# ------------------------------------------------------------
# Metric 3: Context Precision
# "Are the retrieved chunks actually relevant to the question?"
# Score 0-1. Low score = too many irrelevant chunks retrieved.
# ------------------------------------------------------------

def context_precision(question: str, context_chunks: list[str], threshold: float = 0.4) -> dict:
    """

    Measures what fraction of retrieved chunks are relevant to the question.

    Signal-to-noise ratio of retrieval.



    Args:

        question:       The user's question.

        context_chunks: Retrieved chunk texts.

        threshold:      Minimum similarity to consider a chunk relevant.

    """
    if not context_chunks:
        return {"score": 0.0, "relevant": 0, "total": 0, "details": []}

    model = _get_embedder()
    question_embedding = model.encode(question)

    details = []
    relevant = 0

    for i, chunk in enumerate(context_chunks):
        chunk_embedding = model.encode(chunk)
        similarity = _cosine_similarity(question_embedding, chunk_embedding)
        is_relevant = similarity >= threshold
        if is_relevant:
            relevant += 1
        details.append({
            "chunk_index": i,
            "similarity": round(similarity, 4),
            "relevant": is_relevant,
            "preview": chunk[:80] + "..." if len(chunk) > 80 else chunk,
        })

    score = relevant / len(context_chunks)
    logger.debug(f"Context precision: {score:.2f} ({relevant}/{len(context_chunks)} chunks relevant)")
    return {
        "score": round(score, 4),
        "relevant": relevant,
        "total": len(context_chunks),
        "details": details,
    }


# ------------------------------------------------------------
# Metric 4: Context Recall
# "Did we retrieve all the chunks needed to answer fully?"
# Score 0-1. Low score = answer is incomplete due to missing context.
# ------------------------------------------------------------

def context_recall(answer: str, context_chunks: list[str], threshold: float = 0.5) -> dict:
    """

    Measures what fraction of the answer's content is attributable

    to the retrieved context. Inverse of faithfulness framing β€”

    here we measure coverage rather than support.



    Note: Production RAGAS uses ground truth answers for recall.

    Without ground truth, we approximate by checking how much of

    the answer is semantically covered by the context.

    """
    model = _get_embedder()

    sentences = [s.strip() for s in answer.split(".") if s.strip()]
    if not sentences or not context_chunks:
        return {"score": 0.0, "covered": 0, "total": 0}

    chunk_embeddings = model.encode(context_chunks)
    covered = 0

    for sentence in sentences:
        sentence_embedding = model.encode(sentence)
        max_similarity = max(
            _cosine_similarity(sentence_embedding, chunk_emb)
            for chunk_emb in chunk_embeddings
        )
        if max_similarity >= threshold:
            covered += 1

    score = covered / len(sentences)
    logger.debug(f"Context recall: {score:.2f} ({covered}/{len(sentences)} sentences covered)")
    return {
        "score": round(score, 4),
        "covered": covered,
        "total": len(sentences),
    }


# ------------------------------------------------------------
# Combined evaluation β€” run all four metrics at once
# ------------------------------------------------------------

def evaluate(

    question: str,

    answer: str,

    context_chunks: list[str],

    faithfulness_threshold: float = 0.5,

    precision_threshold: float = 0.4,

) -> dict:
    """

    Run all four metrics and return a combined evaluation report.



    Args:

        question:        The user's question.

        answer:          The LLM-generated answer.

        context_chunks:  List of retrieved chunk texts.



    Returns:

        Dict with all four metric results and an overall summary.

    """
    logger.info("Running RAG evaluation...")

    faith = faithfulness(answer, context_chunks, faithfulness_threshold)
    relevancy = answer_relevancy(question, answer)
    precision = context_precision(question, context_chunks, precision_threshold)
    recall = context_recall(answer, context_chunks, faithfulness_threshold)

    # Overall score β€” average of four metrics
    overall = np.mean([
        faith["score"],
        relevancy["score"],
        precision["score"],
        recall["score"],
    ])

    report = {
        "overall": round(float(overall), 4),
        "faithfulness": faith["score"],
        "answer_relevancy": relevancy["score"],
        "context_precision": precision["score"],
        "context_recall": recall["score"],
        "details": {
            "faithfulness": faith,
            "answer_relevancy": relevancy,
            "context_precision": precision,
            "context_recall": recall,
        }
    }

    logger.info(
        f"Evaluation complete β€” "
        f"overall={report['overall']:.2f} | "
        f"faithfulness={faith['score']:.2f} | "
        f"relevancy={relevancy['score']:.2f} | "
        f"precision={precision['score']:.2f} | "
        f"recall={recall['score']:.2f}"
    )

    return report