mirror of
https://github.com/langflow-ai/langflow.git
synced 2026-07-24 01:22:23 +08:00
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:
@ -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
@ -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(
|
||||
|
||||
Reference in New Issue
Block a user