Skip to content

Commit c407087

Browse files
davidsbatistasjrl
andauthored
fix: ElasticSearch match search_term against metadata field value, not content (#3646)
Co-authored-by: Sebastian Husch Lee <10526848+sjrl@users.noreply.github.com>
1 parent 11a1d22 commit c407087

3 files changed

Lines changed: 115 additions & 32 deletions

File tree

integrations/elasticsearch/src/haystack_integrations/document_stores/elasticsearch/document_store.py

Lines changed: 53 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1845,19 +1845,22 @@ def get_metadata_field_unique_values(
18451845
self,
18461846
metadata_field: str,
18471847
search_term: str | None = None,
1848-
size: int | None = 10000,
1848+
size: int | None = 10,
18491849
after: dict[str, Any] | None = None,
18501850
) -> tuple[list[str], dict[str, Any] | None]:
18511851
"""
1852-
Returns unique values for a metadata field, optionally filtered by a search term in the content.
1852+
Returns unique values for a metadata field, optionally filtered by a substring match on the field's value.
18531853
18541854
Uses composite aggregations for proper pagination beyond 10k results.
18551855
18561856
See: https://www.elastic.co/docs/reference/aggregations/search-aggregations-bucket-composite-aggregation
18571857
18581858
:param metadata_field: The metadata field to get unique values for.
1859-
:param search_term: Optional search term to filter documents by matching in the content field.
1860-
:param size: The number of unique values to return per page. Defaults to 10000.
1859+
:param search_term: Optional term to filter the returned values by, matching as a case-insensitive substring
1860+
of the metadata field's own value (not the document content). NOTE: The matching is done with a server-side
1861+
script to accomplish the substring matching on the value of the field and this operation is quite expensive
1862+
for a large corpus.
1863+
:param size: The number of unique values to return per page. Defaults to 10.
18611864
:param after: Optional pagination key from the previous response. Use None for the first page.
18621865
For subsequent pages, pass the `after_key` from the previous response.
18631866
:returns: A tuple containing (list of unique values, after_key for pagination).
@@ -1868,12 +1871,6 @@ def get_metadata_field_unique_values(
18681871

18691872
field_name = _normalize_metadata_field_name(metadata_field)
18701873

1871-
# filter by search_term if provided
1872-
query: dict[str, Any] = {"match_all": {}}
1873-
if search_term:
1874-
# Use match_phrase for exact phrase matching to avoid tokenization issues
1875-
query = {"match_phrase": {"content": search_term}}
1876-
18771874
# Build composite aggregation for proper pagination
18781875
composite_agg: dict[str, Any] = {
18791876
"size": size,
@@ -1882,15 +1879,32 @@ def get_metadata_field_unique_values(
18821879
if after is not None:
18831880
composite_agg["after"] = after
18841881

1885-
body = {
1886-
"query": query,
1882+
body: dict[str, Any] = {
18871883
"aggs": {
18881884
"unique_values": {
18891885
"composite": composite_agg,
18901886
}
18911887
},
18921888
"size": 0, # we only need aggregations, not documents
18931889
}
1890+
if search_term:
1891+
# Composite aggregation terms sources don't support `include`/`exclude` (that's only valid on
1892+
# standalone `terms` aggregations), and a `regexp` query only works on keyword/text fields, not
1893+
# numeric ones. A doc-value script query works uniformly across field types by stringifying the
1894+
# field's value, matching the case-insensitive substring semantics documented above. The term is
1895+
# lower-cased once here rather than per-document in the script.
1896+
body["query"] = {
1897+
"script": {
1898+
"script": {
1899+
"source": (
1900+
"def v = doc[params.field]; "
1901+
"if (v.size() == 0) { return false; } "
1902+
"return v.value.toString().toLowerCase().contains(params.term)"
1903+
),
1904+
"params": {"field": field_name, "term": search_term.lower()},
1905+
}
1906+
}
1907+
}
18941908

18951909
result = self.client.search(index=self._index, body=body)
18961910
aggregations = result.get("aggregations", {})
@@ -1912,19 +1926,23 @@ async def get_metadata_field_unique_values_async(
19121926
self,
19131927
metadata_field: str,
19141928
search_term: str | None = None,
1915-
size: int | None = 10000,
1929+
size: int | None = 10,
19161930
after: dict[str, Any] | None = None,
19171931
) -> tuple[list[str], dict[str, Any] | None]:
19181932
"""
1919-
Asynchronously returns unique values for a metadata field, optionally filtered by a search term in the content.
1933+
Asynchronously returns unique values for a metadata field.
19201934
1935+
Optionally filtered by a substring match on the field's own value.
19211936
Uses composite aggregations for proper pagination beyond 10k results.
19221937
19231938
See: https://www.elastic.co/docs/reference/aggregations/search-aggregations-bucket-composite-aggregation
19241939
19251940
:param metadata_field: The metadata field to get unique values for.
1926-
:param search_term: Optional search term to filter documents by matching in the content field.
1927-
:param size: The number of unique values to return per page. Defaults to 10000.
1941+
:param search_term: Optional term to filter the returned values by, matching as a case-insensitive substring
1942+
of the metadata field's own value (not the document content). NOTE: The matching is done with a server-side
1943+
script to accomplish the substring matching on the value of the field and this operation is quite expensive
1944+
for a large corpus.
1945+
:param size: The number of unique values to return per page. Defaults to 10.
19281946
:param after: Optional pagination key from the previous response. Use None for the first page.
19291947
For subsequent pages, pass the `after_key` from the previous response.
19301948
:returns: A tuple containing (list of unique values, after_key for pagination).
@@ -1935,12 +1953,6 @@ async def get_metadata_field_unique_values_async(
19351953

19361954
field_name = _normalize_metadata_field_name(metadata_field)
19371955

1938-
# filter by search_term if provided
1939-
query: dict[str, Any] = {"match_all": {}}
1940-
if search_term:
1941-
# Use match_phrase for exact phrase matching to avoid tokenization issues
1942-
query = {"match_phrase": {"content": search_term}}
1943-
19441956
# Build composite aggregation for proper pagination
19451957
composite_agg: dict[str, Any] = {
19461958
"size": size,
@@ -1949,15 +1961,32 @@ async def get_metadata_field_unique_values_async(
19491961
if after is not None:
19501962
composite_agg["after"] = after
19511963

1952-
body = {
1953-
"query": query,
1964+
body: dict[str, Any] = {
19541965
"aggs": {
19551966
"unique_values": {
19561967
"composite": composite_agg,
19571968
}
19581969
},
19591970
"size": 0, # we only need aggregations, not documents
19601971
}
1972+
if search_term:
1973+
# Composite aggregation terms sources don't support `include`/`exclude` (that's only valid on
1974+
# standalone `terms` aggregations), and a `regexp` query only works on keyword/text fields, not
1975+
# numeric ones. A doc-value script query works uniformly across field types by stringifying the
1976+
# field's value, matching the case-insensitive substring semantics documented above. The term is
1977+
# lower-cased once here rather than per-document in the script.
1978+
body["query"] = {
1979+
"script": {
1980+
"script": {
1981+
"source": (
1982+
"def v = doc[params.field]; "
1983+
"if (v.size() == 0) { return false; } "
1984+
"return v.value.toString().toLowerCase().contains(params.term)"
1985+
),
1986+
"params": {"field": field_name, "term": search_term.lower()},
1987+
}
1988+
}
1989+
}
19611990

19621991
result = await self.async_client.search(index=self._index, body=body)
19631992
aggregations = result.get("aggregations", {})

integrations/elasticsearch/tests/test_document_store.py

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1290,13 +1290,13 @@ def test_get_metadata_field_unique_values(self, document_store: ElasticsearchDoc
12901290
# Should have no more results
12911291
assert after_key_page2 is None
12921292

1293-
# Test with search term - filter by content matching "Python"
1294-
unique_values_filtered, _ = document_store.get_metadata_field_unique_values("meta.category", "Python", 10)
1295-
assert set(unique_values_filtered) == {"A"} # Only category A has documents with "Python" in content
1293+
# Test with search term - substring match against the target field's own value (not the content)
1294+
# "Java" is a substring of both "Java" and "JavaScript"
1295+
unique_languages_filtered, _ = document_store.get_metadata_field_unique_values("meta.language", "Java", 10)
1296+
assert set(unique_languages_filtered) == {"Java", "JavaScript"}
12961297

1297-
# Test with search term - filter by content matching "Java"
1298-
unique_values_java, _ = document_store.get_metadata_field_unique_values("meta.category", "Java", 10)
1299-
assert set(unique_values_java) == {"B"} # Only category B has documents with "Java" in content
1298+
unique_languages_python, _ = document_store.get_metadata_field_unique_values("meta.language", "Python", 10)
1299+
assert set(unique_languages_python) == {"Python"}
13001300

13011301
# Test with integer values
13021302
int_docs = [
@@ -1309,10 +1309,43 @@ def test_get_metadata_field_unique_values(self, document_store: ElasticsearchDoc
13091309
unique_priorities, _ = document_store.get_metadata_field_unique_values("meta.priority", None, 10)
13101310
assert set(unique_priorities) == {"1", "2", "3"}
13111311

1312-
# Test with search term on integer field
1313-
unique_priorities_filtered, _ = document_store.get_metadata_field_unique_values("meta.priority", "Doc 1", 10)
1312+
# Test with search term on integer field - substring match against the field's own (stringified) value
1313+
unique_priorities_filtered, _ = document_store.get_metadata_field_unique_values("meta.priority", "1", 10)
13141314
assert set(unique_priorities_filtered) == {"1"}
13151315

1316+
def test_get_metadata_field_unique_values_search_term_matches_field_value_not_content(
1317+
self, document_store: ElasticsearchDocumentStore
1318+
):
1319+
"""
1320+
`search_term` must filter by substring match on the metadata field's own value, not by matching
1321+
against the document's `content`.
1322+
"""
1323+
docs = [
1324+
# "Python" appears in the content but NOT in the category value -> must be EXCLUDED
1325+
Document(content="Python programming guide", meta={"category": "Backend"}),
1326+
# "Python" appears in the category value but NOT in the content -> must be INCLUDED
1327+
Document(content="General purpose scripting language", meta={"category": "Python-based"}),
1328+
]
1329+
document_store.write_documents(docs)
1330+
1331+
unique_values, _ = document_store.get_metadata_field_unique_values("meta.category", "Python", 10)
1332+
1333+
assert unique_values == ["Python-based"]
1334+
1335+
def test_get_metadata_field_unique_values_search_term_case_insensitive(
1336+
self, document_store: ElasticsearchDocumentStore
1337+
):
1338+
docs = [
1339+
Document(content="n/a", meta={"category": "Python-based"}),
1340+
Document(content="n/a", meta={"category": "Java-based"}),
1341+
]
1342+
document_store.write_documents(docs)
1343+
1344+
unique_values, _ = document_store.get_metadata_field_unique_values("meta.category", "PYTHON", 10)
1345+
1346+
assert unique_values == ["Python-based"]
1347+
assert "Backend" not in unique_values
1348+
13161349
def test_query_sql(self, document_store: ElasticsearchDocumentStore):
13171350
docs = [
13181351
Document(content="Python programming", meta={"category": "A", "status": "active", "priority": 1}),

integrations/elasticsearch/tests/test_document_store_async.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,3 +364,24 @@ async def test_query_sql_async_error_handling(self, document_store: Elasticsearc
364364
invalid_query = "SELECT * FROM non_existent_index"
365365
with pytest.raises(DocumentStoreError, match="Failed to execute SQL query"):
366366
await document_store._query_sql_async(invalid_query)
367+
368+
@pytest.mark.asyncio
369+
async def test_get_metadata_field_unique_values_async_search_term_matches_field_value_not_content(
370+
self, document_store: ElasticsearchDocumentStore
371+
):
372+
"""
373+
`search_term` must filter by substring match on the metadata field's own value, not by matching
374+
against the document's `content`.
375+
"""
376+
docs = [
377+
# "Python" appears in the content but NOT in the category value -> must be EXCLUDED
378+
Document(content="Python programming guide", meta={"category": "Backend"}),
379+
# "Python" appears in the category value but NOT in the content -> must be INCLUDED
380+
Document(content="General purpose scripting language", meta={"category": "Python-based"}),
381+
]
382+
await document_store.write_documents_async(docs)
383+
384+
unique_values, _ = await document_store.get_metadata_field_unique_values_async("meta.category", "Python", 10)
385+
386+
assert unique_values == ["Python-based"]
387+
assert "Backend" not in unique_values

0 commit comments

Comments
 (0)