Skip to content

Commit 4c56e4c

Browse files
feat(vector_io): add classifier reranker type for post-retrieval chunk filtering
Add "classifier" as a new reranker type that scores chunks by quality or answerability (rather than relevance) and filters below a confidence threshold. Uses the same inference rerank API as neural reranking but with a different objective model. Refs: #5728 Signed-off-by: Varsha Prasad Narsing <vnarsing@redhat.com> Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Varsha Prasad Narsing <varshaprasad96@gmail.com>
1 parent 5317646 commit 4c56e4c

4 files changed

Lines changed: 265 additions & 9 deletions

File tree

src/ogx/providers/utils/memory/openai_vector_store_mixin.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1159,6 +1159,9 @@ def _build_reranker_params(
11591159
reranker_params["weights"] = ranking_options.weights
11601160
elif ranking_options.ranker == "neural":
11611161
reranker_params["model"] = ranking_options.model
1162+
elif ranking_options.ranker == "classifier":
1163+
reranker_params["model"] = ranking_options.model
1164+
reranker_params["confidence_threshold"] = ranking_options.score_threshold or 0.0
11621165
else:
11631166
logger.debug("Unknown ranker value, passing through", ranker=ranking_options.ranker)
11641167

src/ogx/providers/utils/memory/vector_store.py

Lines changed: 94 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ def validate_tiktoken_encoding(name: str = "cl100k_base") -> None:
9494
RERANKER_TYPE_RRF = "rrf"
9595
RERANKER_TYPE_WEIGHTED = "weighted"
9696
RERANKER_TYPE_NORMALIZED = "normalized"
97+
RERANKER_TYPE_CLASSIFIER = "classifier"
9798

9899

99100
def parse_pdf(data: bytes) -> str:
@@ -345,6 +346,8 @@ async def query_chunks(
345346
elif reranker_type == "neural":
346347
# Neural reranking is being applied after initial retrieval
347348
neural_reranking_enabled = True
349+
elif reranker_type == "classifier":
350+
reranker_type = RERANKER_TYPE_CLASSIFIER
348351
elif reranker_type == "normalized":
349352
reranker_type = RERANKER_TYPE_NORMALIZED
350353
else:
@@ -397,8 +400,27 @@ async def query_chunks(
397400
if neural_reranking_enabled and response.chunks:
398401
response = await self.apply_neural_rerank(query_string, response, desired_max_num_results, reranker_params)
399402

403+
# Apply classifier reranking if enabled
404+
if reranker_type == RERANKER_TYPE_CLASSIFIER and response.chunks:
405+
response = await self.apply_classifier_rerank(
406+
query_string, response, desired_max_num_results, reranker_params
407+
)
408+
400409
return response
401410

411+
@staticmethod
412+
def _extract_chunk_texts(
413+
chunks: list[EmbeddedChunk],
414+
) -> list[str | OpenAIChatCompletionContentPartTextParam | OpenAIChatCompletionContentPartImageParam]:
415+
"""Extract text content from chunks for reranking."""
416+
texts: list[str | OpenAIChatCompletionContentPartTextParam | OpenAIChatCompletionContentPartImageParam] = []
417+
for chunk in chunks:
418+
if isinstance(chunk.content, str):
419+
texts.append(chunk.content)
420+
else:
421+
texts.append(interleaved_content_as_str(chunk.content))
422+
return texts
423+
402424
async def apply_neural_rerank(
403425
self,
404426
query_string: str,
@@ -421,15 +443,7 @@ async def apply_neural_rerank(
421443
)
422444
return response
423445

424-
# Extract text contents from chunks for reranking
425-
text_from_chunks: list[
426-
str | OpenAIChatCompletionContentPartTextParam | OpenAIChatCompletionContentPartImageParam
427-
] = []
428-
for chunk in response.chunks:
429-
if isinstance(chunk.content, str):
430-
text_from_chunks.append(chunk.content)
431-
else:
432-
text_from_chunks.append(interleaved_content_as_str(chunk.content))
446+
text_from_chunks = self._extract_chunk_texts(response.chunks)
433447

434448
try:
435449
rerank_response = await self.inference_api.rerank(
@@ -464,6 +478,77 @@ async def apply_neural_rerank(
464478

465479
return QueryChunksResponse(chunks=reranked_chunks, scores=reranked_scores)
466480

481+
async def apply_classifier_rerank(
482+
self,
483+
query_string: str,
484+
response: QueryChunksResponse,
485+
desired_max_num_results: int,
486+
reranker_params: dict[str, Any],
487+
) -> QueryChunksResponse:
488+
"""Rerank and filter chunks using a classifier model via the inference API.
489+
490+
Like neural reranking, this uses an inference model to score chunks.
491+
The difference is the objective: classifier models score for quality or
492+
answerability rather than relevance. Chunks below the confidence threshold
493+
are filtered out.
494+
"""
495+
classifier_model = reranker_params.get("model")
496+
497+
if not classifier_model and self.vector_stores_config and self.vector_stores_config.default_reranker_model:
498+
config = self.vector_stores_config.default_reranker_model
499+
classifier_model = f"{config.provider_id}/{config.model_id}"
500+
501+
if not classifier_model:
502+
log.warning(
503+
"Classifier reranking requested but no model configured. Returning results without classification."
504+
)
505+
return response
506+
507+
text_from_chunks = self._extract_chunk_texts(response.chunks)
508+
509+
try:
510+
rerank_response = await self.inference_api.rerank(
511+
RerankRequest(
512+
model=classifier_model,
513+
query=query_string,
514+
items=text_from_chunks,
515+
max_num_results=desired_max_num_results,
516+
)
517+
)
518+
except Exception as e:
519+
log.error("Classifier reranking failed, returning original results", error=str(e))
520+
return response
521+
522+
confidence_threshold = reranker_params.get("confidence_threshold", 0.0)
523+
524+
classified_chunks = []
525+
classified_scores = []
526+
for classified_chunk in rerank_response.data:
527+
if (
528+
classified_chunk.index < len(response.chunks)
529+
and classified_chunk.relevance_score >= confidence_threshold
530+
):
531+
classified_chunks.append(response.chunks[classified_chunk.index])
532+
classified_scores.append(classified_chunk.relevance_score)
533+
534+
if not classified_chunks:
535+
log.warning(
536+
"Classifier rerank filtered all chunks",
537+
model=classifier_model,
538+
threshold=confidence_threshold,
539+
original_count=len(response.chunks),
540+
)
541+
else:
542+
log.info(
543+
"Classifier rerank complete",
544+
model=classifier_model,
545+
threshold=confidence_threshold,
546+
before=len(response.chunks),
547+
after=len(classified_chunks),
548+
)
549+
550+
return QueryChunksResponse(chunks=classified_chunks, scores=classified_scores)
551+
467552
# Note: File processing for vector stores now happens at the
468553
# openai_attach_file_to_vector_store level using file_id.
469554
# This VectorStoreWithIndex class focuses on chunk operations.

src/ogx_api/vector_io/models.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -482,6 +482,9 @@ class SearchRankingOptions(BaseModel):
482482
- "weighted": Weighted combination of vector and keyword scores
483483
- "rrf": Reciprocal Rank Fusion algorithm
484484
- "neural": Neural reranking model (requires model parameter)
485+
- "classifier": Classification model that scores chunks by quality/answerability and
486+
filters below a confidence threshold (requires model parameter, optional confidence_threshold
487+
via score_threshold)
485488
Note: For OpenAI API compatibility, any string value is accepted, but only the above values are supported.
486489
:param score_threshold: (Optional) Minimum relevance score threshold for results. Default: 0.0
487490
:param alpha: (Optional) Weight factor for weighted ranker (0-1).
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
# Copyright (c) The OGX Contributors.
2+
# All rights reserved.
3+
#
4+
# This source code is licensed under the terms described in the LICENSE file in
5+
# the root directory of this source tree.
6+
7+
"""Tests for the classifier reranker type in the search pipeline."""
8+
9+
from unittest.mock import AsyncMock, MagicMock
10+
11+
import pytest
12+
13+
from ogx.providers.utils.memory.vector_store import (
14+
RERANKER_TYPE_CLASSIFIER,
15+
VectorStoreWithIndex,
16+
)
17+
from ogx_api import ChunkMetadata, EmbeddedChunk, QueryChunksResponse
18+
19+
20+
def _make_chunk(content: str, chunk_id: str = "c1") -> EmbeddedChunk:
21+
return EmbeddedChunk(
22+
content=content,
23+
chunk_id=chunk_id,
24+
metadata={"document_id": chunk_id},
25+
chunk_metadata=ChunkMetadata(document_id=chunk_id, chunk_id=chunk_id),
26+
embedding=[0.1, 0.2, 0.3],
27+
embedding_model="test",
28+
embedding_dimension=3,
29+
)
30+
31+
32+
class TestClassifierRerankerConstant:
33+
def test_classifier_constant_defined(self):
34+
assert RERANKER_TYPE_CLASSIFIER == "classifier"
35+
36+
37+
class TestApplyClassifierRerank:
38+
@pytest.fixture
39+
def mock_store(self):
40+
store = MagicMock(spec=VectorStoreWithIndex)
41+
store.inference_api = AsyncMock()
42+
store.vector_stores_config = None
43+
store.apply_classifier_rerank = VectorStoreWithIndex.apply_classifier_rerank.__get__(store)
44+
store._extract_chunk_texts = VectorStoreWithIndex._extract_chunk_texts
45+
return store
46+
47+
async def test_filters_below_confidence_threshold(self, mock_store):
48+
chunks = [_make_chunk("high quality", "c1"), _make_chunk("low quality", "c2")]
49+
response = QueryChunksResponse(chunks=chunks, scores=[0.9, 0.3])
50+
51+
rerank_data = MagicMock()
52+
rerank_data.data = [
53+
MagicMock(index=0, relevance_score=0.85),
54+
MagicMock(index=1, relevance_score=0.2),
55+
]
56+
mock_store.inference_api.rerank.return_value = rerank_data
57+
58+
result = await mock_store.apply_classifier_rerank(
59+
"test query", response, 5, {"model": "test-classifier", "confidence_threshold": 0.5}
60+
)
61+
62+
assert len(result.chunks) == 1
63+
assert result.chunks[0].content == "high quality"
64+
assert result.scores[0] == 0.85
65+
66+
async def test_keeps_all_above_threshold(self, mock_store):
67+
chunks = [_make_chunk("a", "c1"), _make_chunk("b", "c2")]
68+
response = QueryChunksResponse(chunks=chunks, scores=[0.9, 0.8])
69+
70+
rerank_data = MagicMock()
71+
rerank_data.data = [
72+
MagicMock(index=0, relevance_score=0.9),
73+
MagicMock(index=1, relevance_score=0.8),
74+
]
75+
mock_store.inference_api.rerank.return_value = rerank_data
76+
77+
result = await mock_store.apply_classifier_rerank(
78+
"test query", response, 5, {"model": "test-classifier", "confidence_threshold": 0.1}
79+
)
80+
81+
assert len(result.chunks) == 2
82+
83+
async def test_zero_threshold_keeps_all(self, mock_store):
84+
chunks = [_make_chunk("a", "c1"), _make_chunk("b", "c2")]
85+
response = QueryChunksResponse(chunks=chunks, scores=[0.5, 0.1])
86+
87+
rerank_data = MagicMock()
88+
rerank_data.data = [
89+
MagicMock(index=0, relevance_score=0.5),
90+
MagicMock(index=1, relevance_score=0.1),
91+
]
92+
mock_store.inference_api.rerank.return_value = rerank_data
93+
94+
result = await mock_store.apply_classifier_rerank(
95+
"test query", response, 5, {"model": "test-classifier", "confidence_threshold": 0.0}
96+
)
97+
98+
assert len(result.chunks) == 2
99+
100+
async def test_no_model_returns_original(self, mock_store):
101+
chunks = [_make_chunk("a", "c1")]
102+
response = QueryChunksResponse(chunks=chunks, scores=[0.5])
103+
104+
result = await mock_store.apply_classifier_rerank("test query", response, 5, {})
105+
106+
assert len(result.chunks) == 1
107+
mock_store.inference_api.rerank.assert_not_called()
108+
109+
async def test_inference_error_returns_original(self, mock_store):
110+
chunks = [_make_chunk("a", "c1")]
111+
response = QueryChunksResponse(chunks=chunks, scores=[0.5])
112+
mock_store.inference_api.rerank.side_effect = RuntimeError("model unavailable")
113+
114+
result = await mock_store.apply_classifier_rerank("test query", response, 5, {"model": "bad-model"})
115+
116+
assert len(result.chunks) == 1
117+
assert result.scores[0] == 0.5
118+
119+
async def test_calls_rerank_with_correct_model(self, mock_store):
120+
chunks = [_make_chunk("content", "c1")]
121+
response = QueryChunksResponse(chunks=chunks, scores=[0.5])
122+
123+
rerank_data = MagicMock()
124+
rerank_data.data = [MagicMock(index=0, relevance_score=0.9)]
125+
mock_store.inference_api.rerank.return_value = rerank_data
126+
127+
await mock_store.apply_classifier_rerank("my query", response, 5, {"model": "my-org/quality-classifier"})
128+
129+
call_args = mock_store.inference_api.rerank.call_args[0][0]
130+
assert call_args.model == "my-org/quality-classifier"
131+
assert call_args.query == "my query"
132+
133+
async def test_out_of_bounds_index_ignored(self, mock_store):
134+
chunks = [_make_chunk("only one", "c1")]
135+
response = QueryChunksResponse(chunks=chunks, scores=[0.5])
136+
137+
rerank_data = MagicMock()
138+
rerank_data.data = [
139+
MagicMock(index=0, relevance_score=0.9),
140+
MagicMock(index=99, relevance_score=0.8),
141+
]
142+
mock_store.inference_api.rerank.return_value = rerank_data
143+
144+
result = await mock_store.apply_classifier_rerank("query", response, 5, {"model": "test-model"})
145+
146+
assert len(result.chunks) == 1
147+
assert result.scores[0] == 0.9
148+
149+
async def test_all_filtered_returns_empty(self, mock_store):
150+
chunks = [_make_chunk("low", "c1"), _make_chunk("also low", "c2")]
151+
response = QueryChunksResponse(chunks=chunks, scores=[0.3, 0.2])
152+
153+
rerank_data = MagicMock()
154+
rerank_data.data = [
155+
MagicMock(index=0, relevance_score=0.1),
156+
MagicMock(index=1, relevance_score=0.05),
157+
]
158+
mock_store.inference_api.rerank.return_value = rerank_data
159+
160+
result = await mock_store.apply_classifier_rerank(
161+
"query", response, 5, {"model": "test-model", "confidence_threshold": 0.5}
162+
)
163+
164+
assert len(result.chunks) == 0
165+
assert len(result.scores) == 0

0 commit comments

Comments
 (0)