@@ -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" , {})
0 commit comments