Skip to content

Commit 5382997

Browse files
committed
update tests and clean code
Signed-off-by: Anxhela Coba <acoba@redhat.com> unit tests and PR comments Signed-off-by: Anxhela Coba <acoba@redhat.com> update unit tests, lint and black Signed-off-by: Anxhela Coba <acoba@redhat.com> remove duplicated effort in extracting docs Signed-off-by: Anxhela Coba <acoba@redhat.com> lint and black Signed-off-by: Anxhela Coba <acoba@redhat.com> PR comments incorporated Signed-off-by: Anxhela Coba <acoba@redhat.com> update openai doc and tests Signed-off-by: Anxhela Coba <acoba@redhat.com> fix test Signed-off-by: Anxhela Coba <acoba@redhat.com> fix e2e Signed-off-by: Anxhela Coba <acoba@redhat.com> fix unit Signed-off-by: Anxhela Coba <acoba@redhat.com> comment Signed-off-by: Anxhela Coba <acoba@redhat.com> re-add sort Signed-off-by: Anxhela Coba <acoba@redhat.com> move reranker to new module Signed-off-by: Anxhela Coba <acoba@redhat.com>
1 parent 45e8ac2 commit 5382997

10 files changed

Lines changed: 614 additions & 394 deletions

File tree

docs/openapi.json

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12023,6 +12023,11 @@
1202312023
"$ref": "#/components/schemas/OkpConfiguration",
1202412024
"title": "OKP configuration",
1202512025
"description": "OKP provider settings. Only used when 'okp' is listed in rag.inline or rag.tool."
12026+
},
12027+
"reranker": {
12028+
"$ref": "#/components/schemas/RerankerConfiguration",
12029+
"title": "Reranker configuration",
12030+
"description": "Configuration for neural reranking of RAG chunks using cross-encoder."
1202612031
}
1202712032
},
1202812033
"additionalProperties": false,
@@ -17820,6 +17825,26 @@
1782017825
"title": "ReferencedDocument",
1782117826
"description": "Model representing a document referenced in generating a response.\n\nAttributes:\n doc_url: Url to the referenced doc.\n doc_title: Title of the referenced doc."
1782217827
},
17828+
"RerankerConfiguration": {
17829+
"properties": {
17830+
"enabled": {
17831+
"type": "boolean",
17832+
"title": "Reranker enabled",
17833+
"description": "When True, reranking applied to RAG chunks. When False, reranking is disabled and original scoring used.",
17834+
"default": false
17835+
},
17836+
"model": {
17837+
"type": "string",
17838+
"title": "Reranker model",
17839+
"description": "Cross-encoder model name for reranking RAG chunks. Defaults to 'cross-encoder/ms-marco-MiniLM-L6-v2' from sentence-transformers.",
17840+
"default": "cross-encoder/ms-marco-MiniLM-L6-v2"
17841+
}
17842+
},
17843+
"additionalProperties": false,
17844+
"type": "object",
17845+
"title": "RerankerConfiguration",
17846+
"description": "Reranker configuration for RAG chunk reranking."
17847+
},
1782317848
"ResponseInput": {
1782417849
"anyOf": [
1782517850
{

pyproject.toml

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,10 +73,7 @@ dependencies = [
7373
# To be able to fix multiple CVEs, also LCORE-1117
7474
"requests>=2.33.0",
7575
# Used for RAG chunk reranking (cross-encoder)
76-
"sentence-transformers>=2.0.0",
7776
"datasets>=4.7.0",
78-
# Used for RAG chunk reranking (cross-encoder)
79-
"sentence-transformers>=2.0.0",
8077
# Used for error tracking and monitoring
8178
"sentry-sdk[fastapi]>=2.58.0",
8279
"python-dotenv>=1.2.2",

src/constants.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,9 @@
181181
# Default embedding vector dimension for the sentence transformer model
182182
DEFAULT_EMBEDDING_DIMENSION: Final[int] = 768
183183

184+
# Default sentence transformer cross encoder model for reranking RAG chunk scores
185+
DEFAULT_CROSS_ENCODER_MODEL: Final[str] = "cross-encoder/ms-marco-MiniLM-L6-v2"
186+
184187
# quota limiters constants
185188
USER_QUOTA_LIMITER: Final[str] = "user_limiter"
186189
CLUSTER_QUOTA_LIMITER: Final[str] = "cluster_limiter"
@@ -193,7 +196,7 @@
193196
BYOK_RAG_MAX_CHUNKS: Final[int] = 10 # retrieved from BYOK RAG
194197
OKP_RAG_MAX_CHUNKS: Final[int] = 5 # retrieved from OKP RAG
195198
# Score multiplier applied to BYOK chunks after cross-encoder reranking (Solr chunks unchanged)
196-
BYOK_RAG_RERANK_BOOST = 1.2
199+
BYOK_RAG_RERANK_BOOST: Final[float] = 1.2
197200

198201
# Solr OKP constants
199202
SOLR_VECTOR_SEARCH_DEFAULT_K: Final[int] = 5

src/models/config.py

Lines changed: 20 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1820,49 +1820,28 @@ class OkpConfiguration(ConfigurationBase):
18201820
class RerankerConfiguration(ConfigurationBase):
18211821
"""Reranker configuration for RAG chunk reranking."""
18221822

1823-
enabled: bool = True
1823+
enabled: bool = Field(
1824+
default=False,
1825+
title="Reranker enabled",
1826+
description="When True, reranking applied to RAG chunks. "
1827+
"When False, reranking is disabled and original scoring used.",
1828+
)
18241829
model: str = Field(
18251830
default="cross-encoder/ms-marco-MiniLM-L6-v2",
18261831
title="Reranker model",
18271832
description="Cross-encoder model name for reranking RAG chunks. "
18281833
"Defaults to 'cross-encoder/ms-marco-MiniLM-L6-v2' from sentence-transformers.",
18291834
)
1830-
top_k_multiplier: float = 2.0 # fetch 2x, rerank, keep top_k
1831-
byok_boost: float = 1.2
1832-
okp_boost: float = 1.0
18331835

18341836
# Private attribute to track if this was explicitly configured
18351837
_explicitly_configured: bool = PrivateAttr(default=False)
18361838

18371839
@model_validator(mode="after")
18381840
def mark_as_explicitly_configured(self) -> Self:
18391841
"""Mark this configuration as explicitly set when instantiated from user input."""
1840-
# Only mark as explicitly configured if we're not using all default values
1841-
# This allows auto-enabling when user hasn't touched reranker settings
1842-
# Check if any field differs from default values
1843-
default_model = "cross-encoder/ms-marco-MiniLM-L6-v2"
1844-
default_top_k_multiplier = 2.0
1845-
default_byok_boost = 1.2
1846-
default_okp_boost = 1.0
1847-
1848-
# Check if any setting differs from defaults (indicates explicit configuration)
1849-
current_values = [
1850-
self.enabled,
1851-
self.model,
1852-
self.top_k_multiplier,
1853-
self.byok_boost,
1854-
self.okp_boost,
1855-
]
1856-
default_values = [
1857-
True,
1858-
default_model,
1859-
default_top_k_multiplier,
1860-
default_byok_boost,
1861-
default_okp_boost,
1862-
]
1863-
1864-
if current_values != default_values:
1842+
if self.model_fields_set:
18651843
self._explicitly_configured = True
1844+
18661845
return self
18671846

18681847

@@ -2137,8 +2116,8 @@ def validate_rlsapi_v1_quota_configuration(self) -> Self:
21372116
def validate_reranker_auto_enable(self) -> Self:
21382117
"""Automatically enable reranker when both BYOK and OKP RAG are configured.
21392118
2140-
When users have both BYOK (Bring Your Own Key) entries in byok_rag and OKP
2141-
(OpenShift Knowledge Platform) configured in the RAG strategies, automatically
2119+
When users have both BYOK entries in byok_rag and OKP
2120+
configured in the RAG strategies, automatically
21422121
enable the reranker if it's not explicitly disabled. This improves result
21432122
quality when multiple knowledge sources are available.
21442123
@@ -2150,28 +2129,23 @@ def validate_reranker_auto_enable(self) -> Self:
21502129

21512130
# Check if OKP is configured in either inline or tool RAG strategies
21522131
# pylint: disable=no-member
2153-
has_okp = (
2154-
constants.OKP_RAG_ID in self.rag.inline
2155-
or constants.OKP_RAG_ID in self.rag.tool
2156-
)
2132+
has_okp = constants.OKP_RAG_ID in self.rag.inline
21572133

21582134
# If both BYOK and OKP are present and reranker is using default settings,
21592135
# ensure it's enabled for optimal results
21602136
if (
21612137
has_byok
21622138
and has_okp
2163-
and not hasattr(self.reranker, "_explicitly_configured")
2139+
and not self.reranker._explicitly_configured # pylint: disable=protected-access
2140+
and not self.reranker.enabled
21642141
):
2165-
# pylint: disable=no-member
2166-
if not self.reranker.enabled:
2167-
logger.info(
2168-
"Automatically enabling reranker: Both BYOK RAG (%d entries) and OKP "
2169-
"are configured. Reranking improves result quality when multiple "
2170-
"knowledge sources are available.",
2171-
len(self.byok_rag),
2172-
)
2173-
# pylint: disable=no-member
2174-
self.reranker.enabled = True
2142+
logger.info(
2143+
"Automatically enabling reranker: Both BYOK RAG (%d entries) or "
2144+
"other inline RAG and OKP are configured. Reranking improves result "
2145+
"quality when multiple knowledge sources are available.",
2146+
len(self.byok_rag),
2147+
)
2148+
self.reranker.enabled = True
21752149

21762150
return self
21772151

src/utils/reranker.py

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
"""Reranker utilities for RAG chunk reranking.
2+
3+
This module contains functionality for reranking RAG chunks using cross-encoder models
4+
to improve the relevance of retrieved documents in RAG applications.
5+
"""
6+
7+
import asyncio
8+
from typing import Any
9+
10+
import constants
11+
from configuration import configuration
12+
from log import get_logger
13+
from models.common.turn_summary import RAGChunk
14+
15+
logger = get_logger(__name__)
16+
17+
# Lazy-loaded cross-encoder models for reranking RAG chunks (CPU-bound, use in thread).
18+
# Cache models by name to avoid reloading the same model multiple times.
19+
# Not a constant; pylint invalid-name is disabled for this module-level singleton.
20+
_cross_encoder_models: dict[str, Any] = {} # pylint: disable=invalid-name
21+
_cross_encoder_load_lock = asyncio.Lock()
22+
23+
24+
async def _get_cross_encoder(model_name: str) -> Any:
25+
"""Return the lazy-loaded cross-encoder model for reranking.
26+
27+
Args:
28+
model_name: Name of the cross-encoder model to load.
29+
30+
Returns:
31+
Loaded CrossEncoder model instance, or None if loading fails.
32+
"""
33+
# Check if reranking is enabled before attempting to load the model
34+
if not configuration.reranker.enabled:
35+
logger.debug("Reranker is disabled, not loading cross-encoder model")
36+
return None
37+
38+
if model_name in _cross_encoder_models:
39+
return _cross_encoder_models[model_name]
40+
async with _cross_encoder_load_lock:
41+
if model_name in _cross_encoder_models:
42+
return _cross_encoder_models[model_name]
43+
try:
44+
from sentence_transformers import (
45+
CrossEncoder,
46+
) # pylint: disable=import-outside-toplevel
47+
48+
model = await asyncio.to_thread(CrossEncoder, model_name)
49+
_cross_encoder_models[model_name] = model
50+
logger.info("Loaded cross-encoder for RAG reranking: %s", model_name)
51+
except Exception as e: # pylint: disable=broad-exception-caught
52+
logger.warning(
53+
"Could not load cross-encoder for reranking (%s): %s", model_name, e
54+
)
55+
_cross_encoder_models[model_name] = None
56+
return _cross_encoder_models[model_name]
57+
58+
59+
# pylint: disable=too-many-locals,too-many-branches
60+
async def rerank_chunks_with_cross_encoder(
61+
query: str,
62+
chunks: list[RAGChunk],
63+
top_k: int,
64+
) -> list[RAGChunk]:
65+
"""Rerank chunks using configurable cross-encoder model.
66+
67+
Args:
68+
query: The search query
69+
chunks: RAG chunks to rerank (should contain original weighted scores)
70+
top_k: Number of top chunks to return
71+
72+
Returns:
73+
Top top_k chunks sorted by combined cross-encoder and weighted score (descending)
74+
"""
75+
if not chunks:
76+
return []
77+
78+
try:
79+
# Get the cached cross-encoder model
80+
model_name = constants.DEFAULT_CROSS_ENCODER_MODEL
81+
model = await _get_cross_encoder(model_name)
82+
if model is None:
83+
raise RuntimeError(f"Failed to load cross-encoder model: {model_name}")
84+
85+
logger.debug("Using cross-encoder model: %s", model_name)
86+
87+
# Create query-chunk pairs for scoring
88+
pairs = [(query, chunk.content) for chunk in chunks]
89+
scores = await asyncio.to_thread(model.predict, pairs)
90+
91+
if hasattr(scores, "tolist"):
92+
scores = scores.tolist()
93+
94+
# Normalize cross-encoder scores to [0,1] range using min-max normalization
95+
if len(scores) > 1:
96+
min_score = min(scores)
97+
max_score = max(scores)
98+
score_range = max_score - min_score
99+
if score_range > 0:
100+
normalized_ce_scores = [
101+
(score - min_score) / score_range for score in scores
102+
]
103+
else:
104+
# All scores are identical, assign 0.5 to all
105+
normalized_ce_scores = [0.5] * len(scores)
106+
else:
107+
# Single score, assign 1.0
108+
normalized_ce_scores = [1.0] * len(scores)
109+
110+
# Extract original weighted scores and normalize them
111+
original_scores = [
112+
chunk.score if chunk.score is not None else 0.0 for chunk in chunks
113+
]
114+
115+
if len(original_scores) > 1:
116+
min_orig = min(original_scores)
117+
max_orig = max(original_scores)
118+
orig_range = max_orig - min_orig
119+
if orig_range > 0:
120+
normalized_orig_scores = [
121+
(score - min_orig) / orig_range for score in original_scores
122+
]
123+
else:
124+
# All original scores identical, assign 0.5 to all
125+
normalized_orig_scores = [0.5] * len(original_scores)
126+
else:
127+
# Single score, assign 1.0
128+
normalized_orig_scores = [1.0] * len(original_scores)
129+
130+
# Combine cross-encoder scores with original weighted scores
131+
# (favor original weighted scores)
132+
# This ensures score multipliers are still influential in the final ranking
133+
# Weight: 30% cross-encoder, 70% original weighted scores
134+
combined_scores = [
135+
(0.3 * ce_score + 0.7 * orig_score)
136+
for ce_score, orig_score in zip(
137+
normalized_ce_scores, normalized_orig_scores, strict=True
138+
)
139+
]
140+
141+
# Combine scores with chunks and sort by combined score (descending)
142+
indexed = list(zip(combined_scores, chunks, strict=True))
143+
indexed.sort(key=lambda x: x[0], reverse=True)
144+
top_indexed = indexed[:top_k]
145+
146+
# Log the score combination results
147+
logger.info(
148+
"Cross-encoder scoring completed: combined %d cross-encoder + "
149+
"original scores (30%%/70%% mix), returning top %d chunks",
150+
len(chunks),
151+
len(top_indexed),
152+
)
153+
if logger.isEnabledFor(10): # DEBUG level
154+
for i, (score, chunk) in enumerate(top_indexed[:3]): # Show top 3
155+
logger.debug(
156+
"Reranked chunk %d: source=%s, combined_score=%.3f, content_preview='%.50s...'",
157+
i + 1,
158+
chunk.source,
159+
score,
160+
chunk.content,
161+
)
162+
163+
# Return RAGChunk list with combined scores
164+
return [
165+
RAGChunk(
166+
content=chunk.content,
167+
source=chunk.source,
168+
score=float(score),
169+
attributes=chunk.attributes,
170+
)
171+
for score, chunk in top_indexed
172+
]
173+
174+
except Exception as e: # pylint: disable=broad-exception-caught
175+
logger.warning(
176+
"Cross-encoder reranking failed, falling back to original scoring: %s", e
177+
)
178+
# Fallback: sort by original score and take top_k
179+
sorted_chunks = sorted(
180+
chunks,
181+
key=lambda c: c.score if c.score is not None else float("-inf"),
182+
reverse=True,
183+
)
184+
return sorted_chunks[:top_k]
185+
186+
187+
def apply_byok_rerank_boost(
188+
chunks: list[RAGChunk], boost: float = constants.BYOK_RAG_RERANK_BOOST
189+
) -> list[RAGChunk]:
190+
"""Apply a score multiplier to BYOK chunks (source != OKP) and re-sort by score.
191+
192+
Args:
193+
chunks: RAG chunks after reranking (may be from BYOK or Solr).
194+
boost: Multiplier applied to BYOK chunk scores. Solr chunks unchanged.
195+
196+
Returns:
197+
Same chunks with BYOK scores boosted, sorted by score descending.
198+
"""
199+
boosted = []
200+
for chunk in chunks:
201+
score = chunk.score if chunk.score is not None else float("-inf")
202+
if chunk.source != constants.OKP_RAG_ID:
203+
score = score * boost
204+
boosted.append(
205+
RAGChunk(
206+
content=chunk.content,
207+
source=chunk.source,
208+
score=score,
209+
attributes=chunk.attributes,
210+
)
211+
)
212+
boosted.sort(
213+
key=lambda c: c.score if c.score is not None else float("-inf"),
214+
reverse=True,
215+
)
216+
return boosted

0 commit comments

Comments
 (0)