Spaces:
Sleeping
Sleeping
Amrita P commited on
Commit ·
4e69779
1
Parent(s): 56f3ac6
change to grok
Browse files- .env.example +1 -1
- app_gradio.py +0 -1
- evaluation/judge.py +15 -14
- generation/generator.py +43 -34
- requirements.txt +2 -2
- retrieval/searcher.py +40 -42
.env.example
CHANGED
|
@@ -1 +1 @@
|
|
| 1 |
-
|
|
|
|
| 1 |
+
GROQ_API_KEY=your_key_here
|
app_gradio.py
CHANGED
|
@@ -98,7 +98,6 @@ def answer_question(question: str, history: list[dict], expand_query: bool):
|
|
| 98 |
index=index,
|
| 99 |
k=5,
|
| 100 |
expand_query=expand_query,
|
| 101 |
-
generator=generator,
|
| 102 |
)
|
| 103 |
results = search_resp.chunks
|
| 104 |
|
|
|
|
| 98 |
index=index,
|
| 99 |
k=5,
|
| 100 |
expand_query=expand_query,
|
|
|
|
| 101 |
)
|
| 102 |
results = search_resp.chunks
|
| 103 |
|
evaluation/judge.py
CHANGED
|
@@ -1,18 +1,18 @@
|
|
| 1 |
-
"""LLM-as-judge scoring via
|
| 2 |
|
| 3 |
import logging
|
| 4 |
import os
|
| 5 |
import re
|
| 6 |
import time
|
| 7 |
|
| 8 |
-
|
| 9 |
from dotenv import load_dotenv
|
| 10 |
|
| 11 |
load_dotenv()
|
| 12 |
logger = logging.getLogger(__name__)
|
| 13 |
|
| 14 |
-
_JUDGE_MODEL = "
|
| 15 |
-
_ENV_KEY = "
|
| 16 |
|
| 17 |
_FAITHFULNESS_PROMPT = """\
|
| 18 |
You are a grader assessing whether a RAG system's answer is supported by the context.
|
|
@@ -56,31 +56,32 @@ The answer addresses the main question but omits one important detail.\
|
|
| 56 |
|
| 57 |
|
| 58 |
class LLMJudge:
|
| 59 |
-
"""Thin
|
| 60 |
|
| 61 |
def __init__(self, rate_limit_delay: float = 1.0) -> None:
|
| 62 |
api_key = os.getenv(_ENV_KEY)
|
| 63 |
if not api_key:
|
| 64 |
raise EnvironmentError(
|
| 65 |
-
f"
|
| 66 |
)
|
| 67 |
-
|
| 68 |
-
self._model = genai.GenerativeModel(
|
| 69 |
-
_JUDGE_MODEL,
|
| 70 |
-
generation_config=genai.GenerationConfig(temperature=0.0, max_output_tokens=128),
|
| 71 |
-
)
|
| 72 |
self._delay = rate_limit_delay
|
| 73 |
self._last_call_at: float = 0.0
|
| 74 |
|
| 75 |
def score(self, prompt: str) -> tuple[float, str]:
|
| 76 |
-
"""Call
|
| 77 |
wait = self._delay - (time.monotonic() - self._last_call_at)
|
| 78 |
if wait > 0:
|
| 79 |
time.sleep(wait)
|
| 80 |
try:
|
| 81 |
-
response = self.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
self._last_call_at = time.monotonic()
|
| 83 |
-
return _parse_response(response.
|
| 84 |
except Exception as exc:
|
| 85 |
self._last_call_at = time.monotonic()
|
| 86 |
logger.warning("Judge call failed: %s", exc)
|
|
|
|
| 1 |
+
"""LLM-as-judge scoring via Groq: faithfulness and answer relevance."""
|
| 2 |
|
| 3 |
import logging
|
| 4 |
import os
|
| 5 |
import re
|
| 6 |
import time
|
| 7 |
|
| 8 |
+
from groq import Groq
|
| 9 |
from dotenv import load_dotenv
|
| 10 |
|
| 11 |
load_dotenv()
|
| 12 |
logger = logging.getLogger(__name__)
|
| 13 |
|
| 14 |
+
_JUDGE_MODEL = "llama-3.3-70b-versatile"
|
| 15 |
+
_ENV_KEY = "GROQ_API_KEY"
|
| 16 |
|
| 17 |
_FAITHFULNESS_PROMPT = """\
|
| 18 |
You are a grader assessing whether a RAG system's answer is supported by the context.
|
|
|
|
| 56 |
|
| 57 |
|
| 58 |
class LLMJudge:
|
| 59 |
+
"""Thin Groq wrapper for judge scoring with built-in rate limiting."""
|
| 60 |
|
| 61 |
def __init__(self, rate_limit_delay: float = 1.0) -> None:
|
| 62 |
api_key = os.getenv(_ENV_KEY)
|
| 63 |
if not api_key:
|
| 64 |
raise EnvironmentError(
|
| 65 |
+
f"Groq API key not found. Set {_ENV_KEY} or add it to .env."
|
| 66 |
)
|
| 67 |
+
self._client = Groq(api_key=api_key)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
self._delay = rate_limit_delay
|
| 69 |
self._last_call_at: float = 0.0
|
| 70 |
|
| 71 |
def score(self, prompt: str) -> tuple[float, str]:
|
| 72 |
+
"""Call Groq and return (score 0–1, explanation). Returns (0.0, error) on failure."""
|
| 73 |
wait = self._delay - (time.monotonic() - self._last_call_at)
|
| 74 |
if wait > 0:
|
| 75 |
time.sleep(wait)
|
| 76 |
try:
|
| 77 |
+
response = self._client.chat.completions.create(
|
| 78 |
+
model=_JUDGE_MODEL,
|
| 79 |
+
messages=[{"role": "user", "content": prompt}],
|
| 80 |
+
temperature=0.0,
|
| 81 |
+
max_tokens=128,
|
| 82 |
+
)
|
| 83 |
self._last_call_at = time.monotonic()
|
| 84 |
+
return _parse_response(response.choices[0].message.content)
|
| 85 |
except Exception as exc:
|
| 86 |
self._last_call_at = time.monotonic()
|
| 87 |
logger.warning("Judge call failed: %s", exc)
|
generation/generator.py
CHANGED
|
@@ -3,7 +3,7 @@ import logging
|
|
| 3 |
from dataclasses import dataclass, field
|
| 4 |
from typing import Iterator
|
| 5 |
|
| 6 |
-
|
| 7 |
from dotenv import load_dotenv
|
| 8 |
|
| 9 |
from retrieval.index import SearchResult
|
|
@@ -12,8 +12,8 @@ load_dotenv()
|
|
| 12 |
|
| 13 |
logger = logging.getLogger(__name__)
|
| 14 |
|
| 15 |
-
_MODEL_NAME = "
|
| 16 |
-
_ENV_KEY = "
|
| 17 |
|
| 18 |
_SYSTEM_PROMPT = """\
|
| 19 |
You are a precise document Q&A assistant. Follow these rules without exception:
|
|
@@ -45,11 +45,11 @@ class Answer:
|
|
| 45 |
|
| 46 |
|
| 47 |
class Generator:
|
| 48 |
-
"""Wraps
|
| 49 |
|
| 50 |
-
The API key is read from the
|
| 51 |
.env file in the working directory). A low temperature keeps answers
|
| 52 |
-
factual;
|
| 53 |
"""
|
| 54 |
|
| 55 |
def __init__(
|
|
@@ -61,22 +61,14 @@ class Generator:
|
|
| 61 |
api_key = os.getenv(_ENV_KEY)
|
| 62 |
if not api_key:
|
| 63 |
raise EnvironmentError(
|
| 64 |
-
f"
|
| 65 |
"or add it to a .env file."
|
| 66 |
)
|
| 67 |
|
| 68 |
self._low_threshold = low_confidence_threshold
|
| 69 |
self._medium_threshold = medium_confidence_threshold
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
self._model = genai.GenerativeModel(
|
| 73 |
-
model_name,
|
| 74 |
-
system_instruction=_SYSTEM_PROMPT,
|
| 75 |
-
generation_config=genai.GenerationConfig(
|
| 76 |
-
temperature=0.1,
|
| 77 |
-
max_output_tokens=2048,
|
| 78 |
-
),
|
| 79 |
-
)
|
| 80 |
logger.info("Generator initialised with model: %s", model_name)
|
| 81 |
|
| 82 |
def _confidence_level(self, max_score: float) -> str:
|
|
@@ -92,7 +84,7 @@ class Generator:
|
|
| 92 |
results: list[SearchResult],
|
| 93 |
max_score: float = 1.0,
|
| 94 |
) -> Answer:
|
| 95 |
-
"""Build a grounded prompt from retrieved chunks and call
|
| 96 |
|
| 97 |
Args:
|
| 98 |
question: The user's question string.
|
|
@@ -100,11 +92,11 @@ class Generator:
|
|
| 100 |
Each result's metadata must contain 'text', 'source',
|
| 101 |
and 'page_num' keys (set by IngestionPipeline).
|
| 102 |
max_score: Highest retrieval score from the search step, used to
|
| 103 |
-
gate the
|
| 104 |
|
| 105 |
Returns:
|
| 106 |
Answer with generated text, source metadata, and confidence fields.
|
| 107 |
-
When max_score is below the low threshold,
|
| 108 |
"""
|
| 109 |
if not results:
|
| 110 |
return Answer(
|
|
@@ -118,7 +110,7 @@ class Generator:
|
|
| 118 |
level = self._confidence_level(max_score)
|
| 119 |
|
| 120 |
if level == "low":
|
| 121 |
-
logger.debug("Skipping
|
| 122 |
return Answer(
|
| 123 |
question=question,
|
| 124 |
answer=_LOW_CONFIDENCE_MESSAGE,
|
|
@@ -134,9 +126,17 @@ class Generator:
|
|
| 134 |
"ANSWER:"
|
| 135 |
)
|
| 136 |
|
| 137 |
-
logger.debug("Sending prompt to
|
| 138 |
-
response = self.
|
| 139 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
if level == "medium":
|
| 141 |
answer_text += _MEDIUM_CONFIDENCE_NOTE
|
| 142 |
|
|
@@ -154,19 +154,19 @@ class Generator:
|
|
| 154 |
results: list[SearchResult],
|
| 155 |
max_score: float = 1.0,
|
| 156 |
) -> Iterator[str]:
|
| 157 |
-
"""Stream the answer token-by-token using
|
| 158 |
|
| 159 |
Args:
|
| 160 |
question: The user's question string.
|
| 161 |
results: Ranked SearchResult list (same as generate_answer).
|
| 162 |
-
max_score: Highest retrieval score; gates the
|
| 163 |
appends a warning note for medium-confidence answers.
|
| 164 |
|
| 165 |
Yields:
|
| 166 |
-
Text chunks as they arrive from
|
| 167 |
|
| 168 |
Raises:
|
| 169 |
-
Exception: Re-raises any
|
| 170 |
"""
|
| 171 |
if not results:
|
| 172 |
yield "No relevant context was retrieved to answer this question."
|
|
@@ -175,7 +175,7 @@ class Generator:
|
|
| 175 |
level = self._confidence_level(max_score)
|
| 176 |
|
| 177 |
if level == "low":
|
| 178 |
-
logger.debug("Skipping
|
| 179 |
yield _LOW_CONFIDENCE_MESSAGE
|
| 180 |
return
|
| 181 |
|
|
@@ -186,11 +186,20 @@ class Generator:
|
|
| 186 |
"ANSWER:"
|
| 187 |
)
|
| 188 |
|
| 189 |
-
logger.debug("Sending streaming prompt to
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
|
| 195 |
if level == "medium":
|
| 196 |
yield _MEDIUM_CONFIDENCE_NOTE
|
|
|
|
| 3 |
from dataclasses import dataclass, field
|
| 4 |
from typing import Iterator
|
| 5 |
|
| 6 |
+
from groq import Groq
|
| 7 |
from dotenv import load_dotenv
|
| 8 |
|
| 9 |
from retrieval.index import SearchResult
|
|
|
|
| 12 |
|
| 13 |
logger = logging.getLogger(__name__)
|
| 14 |
|
| 15 |
+
_MODEL_NAME = "llama-3.3-70b-versatile"
|
| 16 |
+
_ENV_KEY = "GROQ_API_KEY"
|
| 17 |
|
| 18 |
_SYSTEM_PROMPT = """\
|
| 19 |
You are a precise document Q&A assistant. Follow these rules without exception:
|
|
|
|
| 45 |
|
| 46 |
|
| 47 |
class Generator:
|
| 48 |
+
"""Wraps Groq (llama-3.3-70b-versatile) for grounded, citation-aware answer generation.
|
| 49 |
|
| 50 |
+
The API key is read from the GROQ_API_KEY environment variable (or a
|
| 51 |
.env file in the working directory). A low temperature keeps answers
|
| 52 |
+
factual; max_tokens caps runaway responses.
|
| 53 |
"""
|
| 54 |
|
| 55 |
def __init__(
|
|
|
|
| 61 |
api_key = os.getenv(_ENV_KEY)
|
| 62 |
if not api_key:
|
| 63 |
raise EnvironmentError(
|
| 64 |
+
f"Groq API key not found. Set the {_ENV_KEY} environment variable "
|
| 65 |
"or add it to a .env file."
|
| 66 |
)
|
| 67 |
|
| 68 |
self._low_threshold = low_confidence_threshold
|
| 69 |
self._medium_threshold = medium_confidence_threshold
|
| 70 |
+
self._model_name = model_name
|
| 71 |
+
self._client = Groq(api_key=api_key)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
logger.info("Generator initialised with model: %s", model_name)
|
| 73 |
|
| 74 |
def _confidence_level(self, max_score: float) -> str:
|
|
|
|
| 84 |
results: list[SearchResult],
|
| 85 |
max_score: float = 1.0,
|
| 86 |
) -> Answer:
|
| 87 |
+
"""Build a grounded prompt from retrieved chunks and call Groq.
|
| 88 |
|
| 89 |
Args:
|
| 90 |
question: The user's question string.
|
|
|
|
| 92 |
Each result's metadata must contain 'text', 'source',
|
| 93 |
and 'page_num' keys (set by IngestionPipeline).
|
| 94 |
max_score: Highest retrieval score from the search step, used to
|
| 95 |
+
gate the API call and set confidence metadata.
|
| 96 |
|
| 97 |
Returns:
|
| 98 |
Answer with generated text, source metadata, and confidence fields.
|
| 99 |
+
When max_score is below the low threshold, the LLM is not called.
|
| 100 |
"""
|
| 101 |
if not results:
|
| 102 |
return Answer(
|
|
|
|
| 110 |
level = self._confidence_level(max_score)
|
| 111 |
|
| 112 |
if level == "low":
|
| 113 |
+
logger.debug("Skipping LLM call: max_score=%.4f below low threshold %.2f", max_score, self._low_threshold)
|
| 114 |
return Answer(
|
| 115 |
question=question,
|
| 116 |
answer=_LOW_CONFIDENCE_MESSAGE,
|
|
|
|
| 126 |
"ANSWER:"
|
| 127 |
)
|
| 128 |
|
| 129 |
+
logger.debug("Sending prompt to Groq (%d chars)", len(prompt))
|
| 130 |
+
response = self._client.chat.completions.create(
|
| 131 |
+
model=self._model_name,
|
| 132 |
+
messages=[
|
| 133 |
+
{"role": "system", "content": _SYSTEM_PROMPT},
|
| 134 |
+
{"role": "user", "content": prompt},
|
| 135 |
+
],
|
| 136 |
+
temperature=0.1,
|
| 137 |
+
max_tokens=2048,
|
| 138 |
+
)
|
| 139 |
+
answer_text = response.choices[0].message.content.strip()
|
| 140 |
if level == "medium":
|
| 141 |
answer_text += _MEDIUM_CONFIDENCE_NOTE
|
| 142 |
|
|
|
|
| 154 |
results: list[SearchResult],
|
| 155 |
max_score: float = 1.0,
|
| 156 |
) -> Iterator[str]:
|
| 157 |
+
"""Stream the answer token-by-token using Groq's streaming API.
|
| 158 |
|
| 159 |
Args:
|
| 160 |
question: The user's question string.
|
| 161 |
results: Ranked SearchResult list (same as generate_answer).
|
| 162 |
+
max_score: Highest retrieval score; gates the API call and
|
| 163 |
appends a warning note for medium-confidence answers.
|
| 164 |
|
| 165 |
Yields:
|
| 166 |
+
Text chunks as they arrive from Groq.
|
| 167 |
|
| 168 |
Raises:
|
| 169 |
+
Exception: Re-raises any Groq API error so the caller can handle it.
|
| 170 |
"""
|
| 171 |
if not results:
|
| 172 |
yield "No relevant context was retrieved to answer this question."
|
|
|
|
| 175 |
level = self._confidence_level(max_score)
|
| 176 |
|
| 177 |
if level == "low":
|
| 178 |
+
logger.debug("Skipping LLM call: max_score=%.4f below low threshold %.2f", max_score, self._low_threshold)
|
| 179 |
yield _LOW_CONFIDENCE_MESSAGE
|
| 180 |
return
|
| 181 |
|
|
|
|
| 186 |
"ANSWER:"
|
| 187 |
)
|
| 188 |
|
| 189 |
+
logger.debug("Sending streaming prompt to Groq (%d chars)", len(prompt))
|
| 190 |
+
stream = self._client.chat.completions.create(
|
| 191 |
+
model=self._model_name,
|
| 192 |
+
messages=[
|
| 193 |
+
{"role": "system", "content": _SYSTEM_PROMPT},
|
| 194 |
+
{"role": "user", "content": prompt},
|
| 195 |
+
],
|
| 196 |
+
temperature=0.1,
|
| 197 |
+
max_tokens=2048,
|
| 198 |
+
stream=True,
|
| 199 |
+
)
|
| 200 |
+
for chunk in stream:
|
| 201 |
+
if chunk.choices[0].delta.content:
|
| 202 |
+
yield chunk.choices[0].delta.content
|
| 203 |
|
| 204 |
if level == "medium":
|
| 205 |
yield _MEDIUM_CONFIDENCE_NOTE
|
requirements.txt
CHANGED
|
@@ -16,8 +16,8 @@ sentence-transformers==3.3.1
|
|
| 16 |
# Vector index
|
| 17 |
faiss-cpu==1.9.0
|
| 18 |
|
| 19 |
-
# LLM (
|
| 20 |
-
|
| 21 |
|
| 22 |
# Utilities
|
| 23 |
numpy==1.26.4
|
|
|
|
| 16 |
# Vector index
|
| 17 |
faiss-cpu==1.9.0
|
| 18 |
|
| 19 |
+
# LLM (Groq)
|
| 20 |
+
groq
|
| 21 |
|
| 22 |
# Utilities
|
| 23 |
numpy==1.26.4
|
retrieval/searcher.py
CHANGED
|
@@ -1,21 +1,17 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import logging
|
|
|
|
| 4 |
import re
|
| 5 |
-
from typing import TYPE_CHECKING
|
| 6 |
-
|
| 7 |
-
import google.generativeai as genai
|
| 8 |
-
|
| 9 |
from dataclasses import dataclass
|
| 10 |
|
|
|
|
|
|
|
| 11 |
from ingestion.embedder import Embedder
|
| 12 |
from retrieval.index import VectorIndex, SearchResult
|
| 13 |
from retrieval.bm25_index import BM25Index
|
| 14 |
from retrieval.reranker import Reranker
|
| 15 |
|
| 16 |
-
if TYPE_CHECKING:
|
| 17 |
-
from generation.generator import Generator
|
| 18 |
-
|
| 19 |
logger = logging.getLogger(__name__)
|
| 20 |
|
| 21 |
|
|
@@ -27,6 +23,8 @@ class SearchResponse:
|
|
| 27 |
|
| 28 |
_reranker = Reranker()
|
| 29 |
|
|
|
|
|
|
|
| 30 |
_EXPANSION_PROMPT = (
|
| 31 |
"Generate 3 alternative phrasings of this question that might match "
|
| 32 |
"different parts of a document. Return only the rephrased questions, "
|
|
@@ -45,7 +43,6 @@ def search(
|
|
| 45 |
bm25_index: BM25Index | None = None,
|
| 46 |
alpha: float = 0.7,
|
| 47 |
expand_query: bool = False,
|
| 48 |
-
generator: Generator | None = None,
|
| 49 |
use_reranking: bool = True,
|
| 50 |
expansion_threshold: float = 0.45,
|
| 51 |
) -> SearchResponse:
|
|
@@ -101,29 +98,26 @@ def search(
|
|
| 101 |
results = _single_search(query, embedder, index, bm25_index, candidates, alpha)
|
| 102 |
|
| 103 |
if expand_query:
|
| 104 |
-
if
|
| 105 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
else:
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
logger.debug("Query expanded to %d variants: %s", len(expansions) + 1, [query] + expansions)
|
| 121 |
-
expanded_results: list[list[SearchResult]] = [results] + [
|
| 122 |
-
_single_search(q, embedder, index, bm25_index, candidates, alpha)
|
| 123 |
-
for q in expansions
|
| 124 |
-
]
|
| 125 |
-
results = _merge_by_best_score(expanded_results, candidates)
|
| 126 |
-
expansion_used = True
|
| 127 |
|
| 128 |
# Capture cosine similarity score before reranker overwrites scores with
|
| 129 |
# cross-encoder values (range -10..+10), which would break confidence gating.
|
|
@@ -141,22 +135,26 @@ def search(
|
|
| 141 |
# Query expansion
|
| 142 |
# ---------------------------------------------------------------------------
|
| 143 |
|
| 144 |
-
def _expand_query(query: str
|
| 145 |
-
"""Ask
|
| 146 |
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
list on any API error so the caller always gets at least the original query.
|
| 150 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
try:
|
| 152 |
-
|
| 153 |
-
expansion_model = genai.GenerativeModel(
|
| 154 |
-
generator._model.model_name,
|
| 155 |
-
generation_config=genai.GenerationConfig(temperature=0.7, max_output_tokens=256),
|
| 156 |
-
)
|
| 157 |
prompt = _EXPANSION_PROMPT.format(question=query)
|
| 158 |
-
response =
|
| 159 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
except Exception as exc:
|
| 161 |
logger.warning("Query expansion failed (%s) — using original query only.", exc)
|
| 162 |
return []
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import logging
|
| 4 |
+
import os
|
| 5 |
import re
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
from dataclasses import dataclass
|
| 7 |
|
| 8 |
+
from groq import Groq
|
| 9 |
+
|
| 10 |
from ingestion.embedder import Embedder
|
| 11 |
from retrieval.index import VectorIndex, SearchResult
|
| 12 |
from retrieval.bm25_index import BM25Index
|
| 13 |
from retrieval.reranker import Reranker
|
| 14 |
|
|
|
|
|
|
|
|
|
|
| 15 |
logger = logging.getLogger(__name__)
|
| 16 |
|
| 17 |
|
|
|
|
| 23 |
|
| 24 |
_reranker = Reranker()
|
| 25 |
|
| 26 |
+
_GROQ_EXPANSION_MODEL = "llama-3.3-70b-versatile"
|
| 27 |
+
|
| 28 |
_EXPANSION_PROMPT = (
|
| 29 |
"Generate 3 alternative phrasings of this question that might match "
|
| 30 |
"different parts of a document. Return only the rephrased questions, "
|
|
|
|
| 43 |
bm25_index: BM25Index | None = None,
|
| 44 |
alpha: float = 0.7,
|
| 45 |
expand_query: bool = False,
|
|
|
|
| 46 |
use_reranking: bool = True,
|
| 47 |
expansion_threshold: float = 0.45,
|
| 48 |
) -> SearchResponse:
|
|
|
|
| 98 |
results = _single_search(query, embedder, index, bm25_index, candidates, alpha)
|
| 99 |
|
| 100 |
if expand_query:
|
| 101 |
+
top_score = results[0].score if results else 0.0
|
| 102 |
+
if top_score >= expansion_threshold:
|
| 103 |
+
logger.debug(
|
| 104 |
+
"Expansion skipped: top_score=%.4f >= threshold=%.4f",
|
| 105 |
+
top_score, expansion_threshold,
|
| 106 |
+
)
|
| 107 |
else:
|
| 108 |
+
logger.debug(
|
| 109 |
+
"Expansion triggered: top_score=%.4f < threshold=%.4f",
|
| 110 |
+
top_score, expansion_threshold,
|
| 111 |
+
)
|
| 112 |
+
expansions = _expand_query(query)
|
| 113 |
+
if expansions:
|
| 114 |
+
logger.debug("Query expanded to %d variants: %s", len(expansions) + 1, [query] + expansions)
|
| 115 |
+
expanded_results: list[list[SearchResult]] = [results] + [
|
| 116 |
+
_single_search(q, embedder, index, bm25_index, candidates, alpha)
|
| 117 |
+
for q in expansions
|
| 118 |
+
]
|
| 119 |
+
results = _merge_by_best_score(expanded_results, candidates)
|
| 120 |
+
expansion_used = True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
|
| 122 |
# Capture cosine similarity score before reranker overwrites scores with
|
| 123 |
# cross-encoder values (range -10..+10), which would break confidence gating.
|
|
|
|
| 135 |
# Query expansion
|
| 136 |
# ---------------------------------------------------------------------------
|
| 137 |
|
| 138 |
+
def _expand_query(query: str) -> list[str]:
|
| 139 |
+
"""Ask Groq for 3 alternative phrasings of the query.
|
| 140 |
|
| 141 |
+
Falls back to an empty list on any API error so the caller always gets
|
| 142 |
+
at least the original query.
|
|
|
|
| 143 |
"""
|
| 144 |
+
api_key = os.getenv("GROQ_API_KEY")
|
| 145 |
+
if not api_key:
|
| 146 |
+
logger.warning("GROQ_API_KEY not set — skipping query expansion.")
|
| 147 |
+
return []
|
| 148 |
try:
|
| 149 |
+
client = Groq(api_key=api_key)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
prompt = _EXPANSION_PROMPT.format(question=query)
|
| 151 |
+
response = client.chat.completions.create(
|
| 152 |
+
model=_GROQ_EXPANSION_MODEL,
|
| 153 |
+
messages=[{"role": "user", "content": prompt}],
|
| 154 |
+
temperature=0.7,
|
| 155 |
+
max_tokens=256,
|
| 156 |
+
)
|
| 157 |
+
return _parse_expansion_lines(response.choices[0].message.content)
|
| 158 |
except Exception as exc:
|
| 159 |
logger.warning("Query expansion failed (%s) — using original query only.", exc)
|
| 160 |
return []
|