feat: opensearch multimodal: support filters, adjust defaults (#12319)

* opensearch multimodal: support filters, adjust defaults

Update OpenSearch multimodal vector store component to parse and apply filter_expression JSON to search queries, wrapping existing queries in a bool with filter clauses and applying limit (size) and min_score from the filter object when present. Also validate filter_expression JSON and raise a clear ValueError on parse errors. Adjust component inputs and defaults: remove "JSON" from input_types, change default auth_mode to "jwt", and set bearer_prefix default to false. Uses existing _coerce_filter_clauses helper to build filter clauses.

* Update component_index.json

* [autofix.ci] apply automated fixes

* [autofix.ci] apply automated fixes

* resolve review comments

* [autofix.ci] apply automated fixes

* fix ruff errors

* [autofix.ci] apply automated fixes

* fix ruff errors

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: himavarshagoutham <himavarshajan17@gmail.com>
Co-authored-by: Himavarsha <40851462+HimavarshaVS@users.noreply.github.com>
This commit is contained in:
Edwin Jose
2026-04-02 08:06:17 -05:00
committed by GitHub
parent 96c9035fc6
commit 8c08e1be7f
3 changed files with 163 additions and 21 deletions

View File

@ -1,5 +1,6 @@
"""Tests for OpenSearch Multi-Model Multi-Embedding Vector Store Component."""
import copy
from typing import Any
from unittest.mock import MagicMock, patch
@ -392,6 +393,67 @@ class TestOpenSearchMultimodalComponent(ComponentTestBaseWithoutClient):
assert component.auth_mode == "Bearer Token"
assert component.bearer_token == "test_bearer_token" # pragma: allowlist secret # noqa: S105
def test_default_auth_mode_and_bearer_prefix_inputs(self, component_class):
"""Test default auth-mode related input values for new instances."""
component = component_class()
auth_mode_input = next(field for field in component.inputs if field.name == "auth_mode")
bearer_prefix_input = next(field for field in component.inputs if field.name == "bearer_prefix")
assert auth_mode_input.value == "jwt"
assert bearer_prefix_input.value is False
def test_raw_search_applies_filter_expression(self, component_class, default_kwargs):
"""Test raw_search applies filter clauses, limit, and score threshold from filter_expression."""
client = MagicMock()
client.search.return_value = {"hits": {"hits": []}}
component = component_class().set(
**default_kwargs,
filter_expression='{"filter":[{"term":{"owner":"user1"}}],"limit":3,"score_threshold":1.2}',
)
with patch.object(component, "build_client", return_value=client):
component.raw_search({"query": {"match": {"text": "python"}}})
body = client.search.call_args.kwargs["body"]
assert body["query"]["bool"]["must"] == [{"match": {"text": "python"}}]
assert body["query"]["bool"]["filter"] == [{"term": {"owner": "user1"}}]
assert body["size"] == 3
assert body["min_score"] == 1.2
def test_raw_search_invalid_filter_expression_json_raises(self, component_class, default_kwargs):
"""Test raw_search raises ValueError for invalid filter_expression JSON."""
component = component_class().set(**default_kwargs, filter_expression='{"filter": [}')
with pytest.raises(ValueError, match="Invalid filter_expression JSON"):
component.raw_search({"query": {"match_all": {}}})
def test_raw_search_filter_expression_requires_json_object(self, component_class, default_kwargs):
"""Test raw_search rejects non-object JSON filter_expression values."""
component = component_class().set(**default_kwargs, filter_expression='["owner"]')
with pytest.raises(TypeError, match="expected a JSON object"):
component.raw_search({"query": {"match_all": {}}})
def test_raw_search_does_not_mutate_input_query_dict(self, component_class, default_kwargs):
"""Test raw_search does not mutate caller-provided query dicts."""
client = MagicMock()
client.search.return_value = {"hits": {"hits": []}}
component = component_class().set(
**default_kwargs,
filter_expression='{"filter":[{"term":{"owner":"user1"}}],"limit":2}',
)
raw_query = {"query": {"match": {"text": "python"}}}
original_query = copy.deepcopy(raw_query)
with patch.object(component, "build_client", return_value=client):
component.raw_search(raw_query)
assert raw_query == original_query
def test_raw_search_rejects_non_integer_limit(self, component_class, default_kwargs):
"""Test raw_search rejects invalid non-integer filter limit values."""
component = component_class().set(**default_kwargs, filter_expression='{"limit":"abc"}')
with pytest.raises(ValueError, match=r"filter_expression\.limit"):
component.raw_search({"query": {"match_all": {}}})
async def test_update_build_config_auth_basic(self, component_class):
"""Test update_build_config with basic authentication."""
component = component_class()

File diff suppressed because one or more lines are too long

View File

@ -154,7 +154,7 @@ class OpenSearchVectorStoreComponentMultimodalMultiEmbedding(LCVectorStoreCompon
},
],
value=[],
input_types=["Data", "JSON"],
input_types=["Data"],
),
StrInput(
name="opensearch_url",
@ -280,7 +280,7 @@ class OpenSearchVectorStoreComponentMultimodalMultiEmbedding(LCVectorStoreCompon
DropdownInput(
name="auth_mode",
display_name="Authentication Mode",
value="basic",
value="jwt",
options=["basic", "jwt"],
info=(
"Authentication method: 'basic' for username/password authentication, "
@ -322,7 +322,7 @@ class OpenSearchVectorStoreComponentMultimodalMultiEmbedding(LCVectorStoreCompon
BoolInput(
name="bearer_prefix",
display_name="Prefix 'Bearer '",
value=True,
value=False,
show=False,
advanced=True,
),
@ -391,7 +391,7 @@ class OpenSearchVectorStoreComponentMultimodalMultiEmbedding(LCVectorStoreCompon
return Data(data={})
if isinstance(raw_query, dict):
query_body = raw_query
query_body = copy.deepcopy(raw_query)
elif isinstance(raw_query, str):
s = raw_query.strip()
@ -414,6 +414,39 @@ class OpenSearchVectorStoreComponentMultimodalMultiEmbedding(LCVectorStoreCompon
msg = f"Unsupported raw_search query type: {type(raw_query)!r}"
raise TypeError(msg)
filter_obj = self._parse_filter_expression()
filter_clauses = self._coerce_filter_clauses(filter_obj)
if filter_clauses:
if "query" in query_body:
original_query = query_body["query"]
query_body["query"] = {
"bool": {
"must": [original_query],
"filter": filter_clauses,
}
}
else:
query_body["query"] = {
"bool": {
"must": [{"match_all": {}}],
"filter": filter_clauses,
}
}
if filter_obj:
# Apply limit if not already set in the raw query
if "size" not in query_body:
limit = self._resolve_limit(filter_obj, default_limit=None)
if limit is not None:
query_body["size"] = limit
# Apply score_threshold / scoreThreshold as min_score if not already set
if "min_score" not in query_body:
score_threshold = self._resolve_score_threshold(filter_obj)
if score_threshold is not None:
query_body["min_score"] = score_threshold
client = self.build_client()
logger.info(f"query: {query_body}")
resp = client.search(
@ -1320,6 +1353,60 @@ class OpenSearchVectorStoreComponentMultimodalMultiEmbedding(LCVectorStoreCompon
context_clauses.append({"terms": {field: values}})
return context_clauses
def _parse_filter_expression(self) -> dict | None:
"""Parse and validate optional filter_expression JSON.
Returns:
Parsed JSON object as a dict, or None when unset/blank.
Raises:
ValueError: If JSON is invalid or does not decode to an object.
"""
filter_expression = getattr(self, "filter_expression", "")
if not isinstance(filter_expression, str) or not filter_expression.strip():
return None
try:
filter_obj = json.loads(filter_expression)
except json.JSONDecodeError as e:
msg = f"Invalid filter_expression JSON: {e}"
raise ValueError(msg) from e
if not isinstance(filter_obj, dict):
msg = "Invalid filter_expression JSON type: expected a JSON object."
raise TypeError(msg)
return filter_obj
def _resolve_limit(self, filter_obj: dict | None, default_limit: int | None) -> int | None:
"""Resolve an integer result limit from filter settings."""
if not filter_obj:
return default_limit
raw_limit = filter_obj.get("limit", default_limit)
if raw_limit is None:
return None
if isinstance(raw_limit, bool):
msg = "Invalid filter_expression.limit: expected a positive integer."
raise TypeError(msg)
try:
limit = int(raw_limit)
except (TypeError, ValueError) as e:
msg = "Invalid filter_expression.limit: expected a positive integer."
raise ValueError(msg) from e
if limit <= 0:
msg = "Invalid filter_expression.limit: expected a positive integer."
raise ValueError(msg)
return limit
def _resolve_score_threshold(self, filter_obj: dict | None) -> float | None:
"""Resolve optional positive min score from filter settings."""
if not filter_obj:
return None
score_threshold = filter_obj.get("score_threshold")
if score_threshold is None:
score_threshold = filter_obj.get("scoreThreshold")
if not isinstance(score_threshold, (int, float)) or score_threshold <= 0:
return None
return float(score_threshold)
def _detect_available_models(self, client: OpenSearch, filter_clauses: list[dict] | None = None) -> list[str]:
"""Detect which embedding models have documents in the index.
@ -1485,13 +1572,7 @@ class OpenSearchVectorStoreComponentMultimodalMultiEmbedding(LCVectorStoreCompon
q = (query or "").strip()
# Parse optional filter expression
filter_obj = None
if getattr(self, "filter_expression", "") and self.filter_expression.strip():
try:
filter_obj = json.loads(self.filter_expression)
except json.JSONDecodeError as e:
msg = f"Invalid filter_expression JSON: {e}"
raise ValueError(msg) from e
filter_obj = self._parse_filter_expression()
if not self.embedding:
msg = "Embedding is required to run hybrid search (KNN + keyword)."
@ -1765,8 +1846,8 @@ class OpenSearchVectorStoreComponentMultimodalMultiEmbedding(LCVectorStoreCompon
all_filters = [*filter_clauses, exists_any_embedding]
# Get limit and score threshold
limit = (filter_obj or {}).get("limit", self.number_of_results)
score_threshold = (filter_obj or {}).get("score_threshold", 0)
limit = self._resolve_limit(filter_obj, default_limit=self.number_of_results)
score_threshold = self._resolve_score_threshold(filter_obj)
# Determine the best aggregation field for filename based on index mapping
filename_agg_field = self._get_filename_agg_field(index_properties)
@ -1817,7 +1898,7 @@ class OpenSearchVectorStoreComponentMultimodalMultiEmbedding(LCVectorStoreCompon
"size": limit,
}
if isinstance(score_threshold, (int, float)) and score_threshold > 0:
if score_threshold is not None:
body["min_score"] = score_threshold
logger.info(