Skip to content

Commit 6cef661

Browse files
authored
Merge pull request #1511 from asimurka/add_solr_mode_request_attribute
LCORE-1429: Added configurable search mode option for solr provider
2 parents f3cf146 + aa1abc1 commit 6cef661

6 files changed

Lines changed: 189 additions & 26 deletions

File tree

docs/rag_guide.md

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -339,7 +339,7 @@ curl -sX POST http://localhost:8080/v1/query \
339339
2. Vector search is performed with configurable parameters:
340340
- `k`: Number of results (default: 5)
341341
- `score_threshold`: Minimum similarity score (default: 0.0)
342-
- `mode`: Search mode (default: "hybrid")
342+
- `mode`: Search mode (default: "hybrid"). Per-request configurable.
343343
3. Results include document metadata and source URLs
344344
4. Document URLs are built based on the `offline` setting:
345345
- **Offline mode**: Uses `parent_id` with Mimir base URL
@@ -357,8 +357,19 @@ okp:
357357
chunk_filter_query: "product:*openshift*"
358358
```
359359

360-
> [!NOTE]
361-
> This static filter is a temporary work-around until dynamic per-request filtering is supported.
360+
Per-request filtering is also available on all inference endpoints via request field **`solr`**: `mode` (`semantic`, `hybrid`, or `lexical`) and `filters` (key:value format). Legacy payloads that omit `mode`/`filters` and send filter key:value pairs at the top level still work with `mode` set to `hybrid`.
361+
362+
Example:
363+
364+
```json
365+
{
366+
"query": "How do I configure routes?",
367+
"solr": {
368+
"mode": "hybrid",
369+
"filters": { "fq": ["product:*openshift*"] }
370+
}
371+
}
372+
```
362373

363374
**Prerequisites:**
364375

docs/responses.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ The following fields are LCORE-specific request extensions and are not part of t
108108
|-------|------|-------------|----------|
109109
| `generate_topic_summary` | boolean | Generate topic summary for new conversations. Default: true | No |
110110
| `shield_ids` | array[string] | Shield IDs to apply. If omitted, all configured shields in LCORE are used | No |
111-
| `solr` | dictionary | Solr vector_io provider query parameters | No |
111+
| `solr` | object | Optional `mode` and `filters`. Legacy top-level filter-only objects are still accepted. | No |
112112

113113

114114
### Field Mappings
@@ -599,7 +599,7 @@ The API introduces extensions that are not part of the OpenResponses specificati
599599

600600
- `generate_topic_summary` (request) — When set to `true` and a new conversation is created, a topic summary is automatically generated and stored in conversation metadata.
601601
- `shield_ids` (request) — Optional list of safety shield IDs to apply. If omitted, all configured shields are used.
602-
- `solr` (request) — Solr vector_io provider query parameters (e.g. filter queries).
602+
- `solr` (request) — Object with optional `mode` (`semantic`, `hybrid`, or `lexical`) and `filters` (Solr vector_io provider payload). Legacy filter-only objects (no `mode`/`filters` wrapper) still work.
603603
- `available_quotas` (response) — Provides real-time quota information from all configured quota limiters.
604604

605605
### System Prompt Resolution

src/models/requests.py

Lines changed: 64 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import json
66
from enum import Enum
7-
from typing import Any, Optional, Self
7+
from typing import Any, Literal, Optional, Self
88

99
from llama_stack_api.openai_responses import (
1010
OpenAIResponseInputTool as InputTool,
@@ -24,7 +24,7 @@
2424
from llama_stack_api.openai_responses import (
2525
OpenAIResponseToolMCP as OutputToolMCP,
2626
)
27-
from pydantic import BaseModel, Field, field_validator, model_validator
27+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
2828

2929
from configuration import configuration
3030
from constants import (
@@ -34,6 +34,7 @@
3434
MEDIA_TYPE_JSON,
3535
MEDIA_TYPE_TEXT,
3636
RESPONSES_REQUEST_MAX_SIZE,
37+
SOLR_VECTOR_SEARCH_DEFAULT_MODE,
3738
)
3839
from log import get_logger
3940
from utils import suid
@@ -122,6 +123,56 @@ class Attachment(BaseModel):
122123
}
123124

124125

126+
class SolrVectorSearchRequest(BaseModel):
127+
"""LCORE Solr inline RAG options for ``vector_io.query`` (mode and provider filters).
128+
129+
Attributes:
130+
mode: Solr vector_io search mode. When omitted, the server default (hybrid) is used.
131+
filters: Solr provider filter payload passed through as params['solr'].
132+
133+
Legacy clients may send a plain JSON object with filter keys only;
134+
that object is accepted as filters with mode unset (server default applies).
135+
"""
136+
137+
model_config = ConfigDict(extra="forbid")
138+
139+
mode: Optional[Literal["semantic", "hybrid", "lexical"]] = Field(
140+
None,
141+
description=(
142+
"Solr vector_io search mode. When omitted, the server default "
143+
f"({SOLR_VECTOR_SEARCH_DEFAULT_MODE!r}) is used."
144+
),
145+
examples=["hybrid", "semantic", "lexical"],
146+
)
147+
filters: Optional[dict[str, Any]] = Field(
148+
None,
149+
description="Solr provider filter payload passed through as params['solr'].",
150+
examples=[{"fq": ["product:*openshift*", "product_version:*4.16*"]}],
151+
)
152+
153+
@model_validator(mode="before")
154+
@classmethod
155+
def coerce_legacy_plain_dict(cls, data: Any) -> Any:
156+
"""Treat a legacy top-level filter dict as filters (backward compatibility).
157+
158+
Args:
159+
data: Raw JSON, typically a dict or None.
160+
161+
Returns:
162+
Normalized dict for Pydantic model validation, or the original non-dict value.
163+
"""
164+
if data is None or not isinstance(data, dict):
165+
return data
166+
if "filters" in data or "mode" in data:
167+
return data
168+
logger.warning(
169+
"Solr inline RAG: sending filter fields at the top level of `solr` without "
170+
"`mode` or `filters` is deprecated and will be removed; use "
171+
'`{"mode": "<semantic|hybrid|lexical>", "filters": {...}}` instead.'
172+
)
173+
return {"mode": None, "filters": data}
174+
175+
125176
class QueryRequest(BaseModel):
126177
"""Model representing a request for the LLM (Language Model).
127178
@@ -137,6 +188,7 @@ class QueryRequest(BaseModel):
137188
media_type: The optional media type for response format (application/json or text/plain).
138189
vector_store_ids: The optional list of specific vector store IDs to query for RAG.
139190
shield_ids: The optional list of safety shield IDs to apply.
191+
solr: Optional Solr inline RAG options (mode, filters) or legacy filter-only dict.
140192
141193
Example:
142194
```python
@@ -227,13 +279,18 @@ class QueryRequest(BaseModel):
227279
examples=["llama-guard", "custom-shield"],
228280
)
229281

230-
solr: Optional[dict[str, Any]] = Field(
282+
solr: Optional[SolrVectorSearchRequest] = Field(
231283
None,
232-
description="Solr-specific query parameters including filter queries",
284+
description=(
285+
"Solr inline RAG config: mode (semantic, hybrid, lexical) and filters; "
286+
"a legacy filter-only object (e.g. fq) is still accepted."
287+
),
233288
examples=[
234-
{"fq": ["product:*openshift*", "product_version:*4.16*"]},
289+
{"mode": "hybrid", "filters": {"fq": ["product:*openshift*"]}},
290+
{"filters": {"fq": ["product:*openshift*", "product_version:*4.16*"]}},
235291
],
236292
)
293+
237294
# provides examples for /docs endpoint
238295
model_config = {
239296
"extra": "forbid",
@@ -695,8 +752,7 @@ class ResponsesRequest(BaseModel):
695752
topic summary for new conversations. Defaults to True.
696753
shield_ids: LCORE-specific list of safety shield IDs to apply. If None, all
697754
configured shields are used.
698-
solr: LCORE-specific Solr vector_io provider query parameters (e.g. filter
699-
queries). Optional.
755+
solr: Optional Solr inline RAG options (mode, filters) or legacy filter-only dict.
700756
"""
701757

702758
input: ResponseInput
@@ -722,7 +778,7 @@ class ResponsesRequest(BaseModel):
722778
# LCORE-specific attributes
723779
generate_topic_summary: Optional[bool] = True
724780
shield_ids: Optional[list[str]] = None
725-
solr: Optional[dict[str, Any]] = None
781+
solr: Optional[SolrVectorSearchRequest] = None
726782

727783
model_config = {
728784
"extra": "forbid",

src/utils/vector_search.py

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import constants
1818
from configuration import configuration
1919
from log import get_logger
20+
from models.requests import SolrVectorSearchRequest
2021
from models.responses import ReferencedDocument
2122
from utils.responses import resolve_vector_store_ids
2223
from utils.types import RAGChunk, RAGContext, ResponseInput
@@ -52,18 +53,32 @@ def _get_solr_vector_store_ids() -> list[str]:
5253
return vector_store_ids
5354

5455

55-
def _build_query_params(solr: Optional[dict[str, Any]] = None) -> dict[str, Any]:
56-
"""Build query parameters for vector search."""
56+
def _build_query_params(
57+
solr: Optional[SolrVectorSearchRequest] = None,
58+
) -> dict[str, Any]:
59+
"""Build query parameters for Solr vector_io search.
60+
61+
Args:
62+
solr: Optional structured Solr request (mode and filters from the API).
63+
64+
Returns:
65+
Parameter dictionary for ``vector_io.query``.
66+
"""
67+
resolved_mode = (
68+
solr.mode
69+
if solr is not None and solr.mode is not None
70+
else constants.SOLR_VECTOR_SEARCH_DEFAULT_MODE
71+
)
5772
params: dict[str, Any] = {
5873
"k": constants.SOLR_VECTOR_SEARCH_DEFAULT_K,
5974
"score_threshold": constants.SOLR_VECTOR_SEARCH_DEFAULT_SCORE_THRESHOLD,
60-
"mode": constants.SOLR_VECTOR_SEARCH_DEFAULT_MODE,
75+
"mode": resolved_mode,
6176
}
6277
logger.debug("Initial params: %s", params)
6378
logger.debug("query_request.solr: %s", solr)
6479

65-
if solr is not None:
66-
params["solr"] = solr
80+
if solr is not None and solr.filters is not None:
81+
params["solr"] = solr.filters
6782
logger.debug("Final params with solr filters: %s", params)
6883
else:
6984
logger.debug("No solr filters provided")
@@ -438,14 +453,14 @@ async def _fetch_byok_rag(
438453
async def _fetch_solr_rag(
439454
client: AsyncLlamaStackClient,
440455
query: str,
441-
solr: Optional[dict[str, Any]] = None,
456+
solr: Optional[SolrVectorSearchRequest] = None,
442457
) -> tuple[list[RAGChunk], list[ReferencedDocument]]:
443458
"""Fetch chunks and documents from Solr RAG source.
444459
445460
Args:
446461
client: The AsyncLlamaStackClient to use for the request
447462
query: The user's query
448-
solr: Solr query parameters
463+
solr: Structured Solr inline RAG request from the API (optional).
449464
450465
Returns:
451466
Tuple containing:
@@ -516,7 +531,7 @@ async def build_rag_context(
516531
moderation_decision: str,
517532
query: str,
518533
vector_store_ids: Optional[list[str]],
519-
solr: Optional[dict[str, Any]] = None,
534+
solr: Optional[SolrVectorSearchRequest] = None,
520535
) -> RAGContext:
521536
"""Build RAG context by fetching and merging chunks from all enabled sources.
522537
@@ -527,7 +542,7 @@ async def build_rag_context(
527542
moderation_decision: The moderation decision
528543
query: The user's query
529544
vector_store_ids: The vector store IDs to query
530-
solr: The Solr query parameters
545+
solr: Structured Solr inline RAG request from the API (optional).
531546
532547
Returns:
533548
RAGContext containing formatted context text and referenced documents

tests/unit/models/requests/test_query_request.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import pytest
66
from pytest_mock import MockerFixture
77

8-
from models.requests import Attachment, QueryRequest
8+
from models.requests import Attachment, QueryRequest, SolrVectorSearchRequest
99

1010

1111
class TestQueryRequest:
@@ -140,3 +140,23 @@ def test_generate_topic_summary_explicit_true(self) -> None:
140140
query="Tell me about Kubernetes", generate_topic_summary=True
141141
) # pyright: ignore[reportCallIssue]
142142
assert qr.generate_topic_summary is True
143+
144+
def test_solr_legacy_plain_dict(self) -> None:
145+
"""Legacy clients may send filter keys as a plain object on ``solr``."""
146+
qr = QueryRequest(
147+
query="q",
148+
solr={"fq": ["a:b"]},
149+
) # pyright: ignore[reportCallIssue]
150+
solr_request = SolrVectorSearchRequest.model_validate(qr.solr)
151+
assert solr_request.mode is None
152+
assert solr_request.filters == {"fq": ["a:b"]}
153+
154+
def test_solr_structured_mode_and_filters(self) -> None:
155+
"""New clients send ``mode`` and ``filters`` under ``solr``."""
156+
qr = QueryRequest(
157+
query="q",
158+
solr={"mode": "hybrid", "filters": {"fq": ["x:y"]}},
159+
) # pyright: ignore[reportCallIssue]
160+
solr_request = SolrVectorSearchRequest.model_validate(qr.solr)
161+
assert solr_request.mode == "hybrid"
162+
assert solr_request.filters == {"fq": ["x:y"]}

tests/unit/utils/test_vector_search.py

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import constants
88
from configuration import AppConfig
9+
from models.requests import SolrVectorSearchRequest
910
from utils.types import RAGChunk
1011
from utils.vector_search import (
1112
_build_document_url,
@@ -68,12 +69,38 @@ def test_default_params(self) -> None:
6869

6970
def test_with_solr_filters(self) -> None:
7071
"""Test parameters when solr filters are provided."""
71-
solr_filters = {"filter": "value"}
72-
params = _build_query_params(solr=solr_filters)
72+
solr = SolrVectorSearchRequest.model_validate({"filter": "value"})
73+
params = _build_query_params(solr=solr)
7374

74-
assert params["solr"] == solr_filters
75+
assert params["solr"] == {"filter": "value"}
7576
assert params["k"] == constants.SOLR_VECTOR_SEARCH_DEFAULT_K
7677

78+
def test_custom_mode(self) -> None:
79+
"""Request mode overrides the default Solr vector_io mode."""
80+
solr = SolrVectorSearchRequest(mode="lexical")
81+
params = _build_query_params(solr=solr)
82+
83+
assert params["mode"] == "lexical"
84+
assert "solr" not in params
85+
86+
def test_mode_with_solr_filters(self) -> None:
87+
"""Custom mode is combined with solr filter payload."""
88+
solr = SolrVectorSearchRequest(
89+
mode="semantic", filters={"fq": ["product:*openshift*"]}
90+
)
91+
params = _build_query_params(solr=solr)
92+
93+
assert params["mode"] == "semantic"
94+
assert params["solr"] == {"fq": ["product:*openshift*"]}
95+
96+
def test_mode_with_only_filters(self) -> None:
97+
"""Mode is set to default value when only filters are provided."""
98+
solr = SolrVectorSearchRequest(filters={"fq": ["product:*openshift*"]})
99+
params = _build_query_params(solr=solr)
100+
101+
assert params["mode"] == constants.SOLR_VECTOR_SEARCH_DEFAULT_MODE
102+
assert params["solr"] == {"fq": ["product:*openshift*"]}
103+
77104

78105
class TestExtractByokRagChunks:
79106
"""Tests for _extract_byok_rag_chunks function."""
@@ -604,6 +631,40 @@ async def test_solr_enabled_success(self, mocker: MockerFixture) -> None:
604631
assert rag_chunks[0].content == "Solr content"
605632
assert rag_chunks[0].source == constants.OKP_RAG_ID
606633

634+
@pytest.mark.asyncio
635+
async def test_solr_enabled_passes_request_mode_to_vector_io(
636+
self, mocker: MockerFixture
637+
) -> None:
638+
"""OKP vector_io.query receives the mode from the API request."""
639+
config_mock = mocker.Mock(spec=AppConfig)
640+
config_mock.inline_solr_enabled = True
641+
config_mock.okp.offline = True
642+
config_mock.okp.rhokp_url = "https://okp.test"
643+
mocker.patch("utils.vector_search.configuration", config_mock)
644+
645+
chunk_mock = mocker.Mock()
646+
chunk_mock.content = "Solr content"
647+
chunk_mock.metadata = {"parent_id": "parent_1", "title": "Solr Doc"}
648+
chunk_mock.chunk_metadata = None
649+
650+
query_response = mocker.Mock()
651+
query_response.chunks = [chunk_mock]
652+
query_response.scores = [0.85]
653+
654+
client_mock = mocker.AsyncMock()
655+
client_mock.vector_io.query.return_value = query_response
656+
657+
await _fetch_solr_rag(
658+
client_mock,
659+
"test query",
660+
SolrVectorSearchRequest(mode="semantic", filters={"fq": ["x:y"]}),
661+
)
662+
663+
client_mock.vector_io.query.assert_called_once()
664+
call_kwargs = client_mock.vector_io.query.call_args.kwargs
665+
assert call_kwargs["params"]["mode"] == "semantic"
666+
assert call_kwargs["params"]["solr"] == {"fq": ["x:y"]}
667+
607668

608669
class TestBuildRagContext:
609670
"""Tests for build_rag_context async function."""

0 commit comments

Comments
 (0)