Link Search Menu Expand Document Documentation Menu

Normalization processor

Introduced 2.10

The normalization-processor is a search phase results processor that runs between the query and fetch phases of search execution. It intercepts the query phase results and then normalizes and combines the document scores from different query clauses before passing the documents to the fetch phase.

Score normalization and combination

Many applications require both keyword matching and semantic understanding. For example, BM25 accurately provides relevant search results for a query containing keywords, and neural networks perform well when a query requires natural language understanding. Thus, you might want to combine BM25 search results with the results of a k-NN or neural search. However, BM25 and k-NN search use different scales to calculate relevance scores for the matching documents. Before combining the scores from multiple queries, it is beneficial to normalize them so that they are on the same scale, as shown by experimental data. For further reading about score normalization and combination, including benchmarks and various techniques, see this semantic search blog post.

Normalization techniques

OpenSearch normalizes each query clause independently. For a given clause, it calculates the statistics that the technique requires from that clause’s own results only, and then rescales every score in the clause. Documents that the clause did not return contribute nothing to its statistics.

Because the statistics are derived from the returned results rather than from the whole index, the normalized scores depend on how many results each clause returns. For more information, see Search tuning recommendations.

Min-max normalization

Min-max normalization rescales the scores of a query clause to the [0.0, 1.0] range by subtracting the clause’s minimum score and dividing by the clause’s score range:

\[\text{n_score} = \frac {\text{score} - \text{min_score}} {\text{max_score} - \text{min_score}}\]

The highest-scoring document in the clause receives a score of 1.0. OpenSearch replaces a normalized score of exactly 0.0 with 0.001 because a score of 0.0 has the special meaning of match_none, so the lowest-scoring document in the clause receives 0.001. If every document in a clause has the same score, including a clause that returns only one document, then the clause’s minimum and maximum scores are equal and every document in the clause receives 1.0.

Because only an exact 0.0 is replaced, this substitution can reorder the low-scoring documents in a clause. Any document whose normalized score is less than 0.001 ranks below the lowest-scoring document, which receives exactly 0.001. For example, a clause that returns the raw scores 10000, 9, and 8 normalizes them to 1.0, 0.00010008, and 0.001, so the document scoring 8 ranks above the document scoring 9. A document is affected when its score exceeds the clause minimum by less than 0.1% of the clause’s score range, which occurs when one score in the clause is roughly 1,000 times larger than the others.

To normalize against fixed thresholds instead of the minimum and maximum scores of the returned results, set the lower_bounds and upper_bounds parameters. For more information, see Request body fields.

L2 normalization

L2 normalization divides each score in a query clause by the Euclidean norm of all scores in that clause:

\[\text{n_score}_i = \frac {\text{score}_i} {\sqrt{\text{score}_1^2 + \text{score}_2^2 + \dots + \text{score}_n^2}}\]

The normalized scores preserve the ratios of the original scores, so a document that scored twice as high as another document in the same clause still scores twice as high after normalization. Because every score is divided by the same norm, and no single score can exceed the norm, the normalized scores fall in the [0.0, 1.0] range. Unlike min-max normalization, L2 normalization does not assign 1.0 to the highest-scoring document. Each additional result that a clause returns increases the norm and therefore reduces the normalized score of every other document in the clause. Only when a clause returns a single document does the norm equal that document’s score, and the document receives 1.0.

Z-score normalization

Z-score normalization subtracts the mean of the query clause’s scores from each score and divides the result by the sample standard deviation of those scores:

\[\text{n_score} = \frac {\text{score} - \text{mean}} {\text{sd}}\]

A document that scores below the mean of its clause produces a negative z-score. Because a negative value is not a valid relevance score, OpenSearch replaces every normalized score less than or equal to 0.0 with 0.001. All documents that score below the mean of a query clause therefore receive the same normalized score, and their relative order within that clause is lost. Documents that score above the mean are not confined to a fixed range and can receive normalized scores greater than 1.0.

A document whose score is exactly equal to the mean of its clause is a special case: it receives the highest raw score in the clause rather than a z-score. If every document in a clause has the same score, including a clause that returns only one document, then every document matches the mean, so the clause’s scores are not rescaled at all.

The z_score technique supports only the arithmetic_mean combination technique.

Choosing a normalization technique

Use the following table to compare the three techniques.

  min_max l2 z_score
Output range [0.0, 1.0], with 0.0 replaced by 0.001 [0.0, 1.0] Not a fixed range, with all values less than or equal to 0.0 replaced by 0.001
Score of the highest-scoring document in a clause Always 1.0 Less than 1.0, unless the clause returns a single document Varies, and can exceed 1.0
Score ratios within a clause Not preserved Preserved Not preserved
Statistics used Lowest and highest scores All scores All scores
Effect of a single outlier on the other documents in a clause Compresses their scores but keeps them distinct Compresses their scores but keeps them distinct Pushes them below the clause mean, so they all collapse to 0.001
Ordering of documents within a clause Preserved, except that documents with normalized scores below 0.001 rank below the lowest-scoring document Preserved Lost for every document that scores below the clause mean
Supported combination techniques All All arithmetic_mean

The following guidance applies to each technique:

  • Use min_max, the default, when you want normalized scores on a predictable scale that you can reason about directly, or when you want to normalize against fixed thresholds through the lower_bounds and upper_bounds parameters.
  • Use l2 when the ratios between the scores within a query clause carry information that the final ranking should reflect. Note that l2 does not spread out the remaining documents more than min_max does: because a clause’s L2 norm is always greater than or equal to its score range, l2 separates any two documents by less than or equal to the amount that min_max separates them.
  • Use z_score when the query clauses produce score distributions that differ mainly in spread. Avoid it when a clause returns few results or contains an outlier, because every document that scores below the clause mean collapses to 0.001. A single high outlier can pull the mean above all remaining scores and erase the ordering of every other document in the clause.

Example: Comparing normalization techniques

The following example applies all three techniques to the same data so that you can compare their output. It uses two-dimensional vectors specified directly in each document so that you can verify the arithmetic manually, and a single shard so that the scores are reproducible.

The example uses the same index and query as Reciprocal rank fusion, so you can also compare these results to those of rank-based combination.

Create an index with a text field for keyword matching and a vector field for semantic matching:

PUT /products
{
  "settings": {
    "index.knn": true,
    "number_of_shards": 1
  },
  "mappings": {
    "properties": {
      "item_name": {
        "type": "text"
      },
      "item_vector": {
        "type": "knn_vector",
        "dimension": 2,
        "space_type": "l2",
        "method": {
          "name": "hnsw",
          "engine": "lucene"
        }
      }
    }
  }
}

Index five products:

POST /_bulk?refresh=true
{ "index": { "_index": "products", "_id": "1" } }
{ "item_name": "kids running shoes", "item_vector": [0.2, 0.8] }
{ "index": { "_index": "products", "_id": "2" } }
{ "item_name": "mens lightweight running shoes for road racing", "item_vector": [0.9, 0.1] }
{ "index": { "_index": "products", "_id": "3" } }
{ "item_name": "trail runners", "item_vector": [0.8, 0.2] }
{ "index": { "_index": "products", "_id": "4" } }
{ "item_name": "athletic socks", "item_vector": [0.5, 0.5] }
{ "index": { "_index": "products", "_id": "5" } }
{ "item_name": "winter parka", "item_vector": [0.0, 1.0] }

Run separately, the two query clauses produce scores on different scales. The match clause on running shoes returns two documents, and a knn clause on [0.9, 0.1] with k set to 3 returns three documents:

Document ID item_name Keyword score Vector score
1 kids running shoes 0.8167638
2 mens lightweight running shoes for road racing 0.5356597 1.0
3 trail runners 0.98039216
4 athletic socks 0.7575758

Create one search pipeline for each normalization technique, replacing min_max with l2 and z_score to create the other two:

PUT /_search/pipeline/min-max-pipeline
{
  "description": "Normalize with min_max and combine with arithmetic_mean",
  "phase_results_processors": [
    {
      "normalization-processor": {
        "normalization": {
          "technique": "min_max"
        },
        "combination": {
          "technique": "arithmetic_mean"
        }
      }
    }
  ]
}

Combine both query clauses in a hybrid query and apply one of the pipelines:

GET /products/_search?search_pipeline=min-max-pipeline
{
  "_source": {
    "excludes": ["item_vector"]
  },
  "query": {
    "hybrid": {
      "queries": [
        {
          "match": {
            "item_name": "running shoes"
          }
        },
        {
          "knn": {
            "item_vector": {
              "vector": [0.9, 0.1],
              "k": 3
            }
          }
        }
      ]
    }
  }
}

Because the weights parameter is omitted, arithmetic_mean weights both clauses equally. A document that one clause did not return contributes 0 to the numerator but still counts in the denominator, so a document matching only one of the two clauses has its normalized score halved.

With min_max, document 1 normalizes to 1.0 because it is the highest-scoring document of the keyword clause, and document 2 normalizes to 0.001 because it is the lowest-scoring document of that clause. Document 2 still ranks first overall, but only narrowly:

Document ID Keyword normalized Vector normalized _score
2 0.001 1.0 0.5005
1 1.0 0.5
3 0.9191176 0.4595588
4 0.001 0.0005

With l2, each clause is divided by its own norm: 0.97674686 for the keyword clause and 1.5921966 for the vector clause. No score is forced to an endpoint, so document 2 retains a larger share of its keyword contribution and its lead over document 1 widens:

Document ID Keyword normalized Vector normalized _score
2 0.54841197 0.62806314 0.5882375
1 0.8362083 0.41810414
3 0.61574817 0.30787408
4 0.47580546 0.23790273

With z_score, documents 2 and 4 fall at or below the mean of their respective clauses, so both receive 0.001 in that clause. Document 2 loses most of its keyword contribution as a result, and document 1 ranks first:

Document ID Keyword normalized Vector normalized _score
1 0.70710695 0.35355347
2 0.001 0.6486226 0.32481128
3 0.5030134 0.2515067
4 0.001 0.0005

The three techniques produce three different rankings from identical input, and z_score promotes a different document to the top. Evaluate the techniques against your own data and judgment lists rather than choosing one from these results. For more information, see Optimizing hybrid search.

Query then fetch

OpenSearch supports two search types: query_then_fetch and dfs_query_then_fetch. The following diagram outlines the query-then-fetch process, which includes a normalization processor.

Normalization processor flow diagram

When you send a search request to a node, the node becomes a coordinating node. During the first phase of search, the query phase, the coordinating node routes the search request to all shards in the index, including primary and replica shards. Each shard then runs the search query locally and returns metadata about the matching documents, which includes their document IDs and relevance scores. The normalization-processor then normalizes and combines scores from different query clauses. The coordinating node merges and sorts the local lists of results, compiling a global list of top documents that match the query. After that, search execution enters a fetch phase, in which the coordinating node requests the documents in the global list from the shards where they reside. Each shard returns the documents’ _source to the coordinating node. Finally, the coordinating node sends a search response containing the results back to you.

Request body fields

The following table lists all available request fields.

Field Data type Description
normalization.technique String The technique for normalizing scores. Valid values are min_max, l2, and z_score. Optional. Default is min_max.
normalization.parameters.lower_bounds Array of objects Defines the lower bound values (the minimum threshold scores) for each query. The array must contain the same number of objects as the number of queries. Optional. Applies only when the normalization technique is min_max. If not provided, OpenSearch does not apply a lower bound to any subquery and uses the actual minimum score from the retrieved results for normalization.
normalization.parameters.lower_bounds.mode String Specifies how the lower bound is applied to a query. Valid values are:
- apply: Uses min_score for normalization without modifying the original scores. Formula: min_max_score = if (score < lowerBoundScore) then (score - minScore) / (maxScore - minScore) else (score - lowerBoundScore) / (maxScore - lowerBoundScore).
- clip: Replaces scores below the lower bound with min_score. Formula: min_max_score = if (score < lowerBoundScore) then 0.0 else (score - lowerBoundScore) / (maxScore - lowerBoundScore).
- ignore: Does not apply a lower bound to this query and uses the standard min_max formula instead.
Optional. Default is apply.
normalization.parameters.lower_bounds.min_score Float The lower bound threshold. Valid values are in the [-10000.0, 10000.0] range. If mode is set to ignore, then this value has no effect. Optional. Default is 0.0.
normalization.parameters.upper_bounds Array of objects Defines the upper bound values (the maximum threshold scores) for each query. The array must contain the same number of objects as the number of queries. Optional. Applies only when the normalization.technique is set to min_max. If not provided, OpenSearch does not apply an upper bound to any subquery and uses the actual maximum score from the retrieved results for normalization.
normalization.parameters.upper_bounds.mode String Specifies how the upper bound is applied to a query. Valid values are:
- apply: Uses max_score for normalization without modifying the original scores. Formula: min_max_score = if (score > upperBoundScore) then (score - minScore) / (maxScore - minScore) else (score - minScore) / (upperBoundScore - minScore).
- clip: Replaces scores above the upper bound with max_score. Formula: min_max_score = if (score > upperBoundScore) then 1.0 else (score - minScore) / (upperBoundScore - minScore).
- ignore: Does not apply an upper bound to this query and uses the standard min_max formula instead.
Optional. Default is apply.
normalization.parameters.upper_bounds.max_score Float The upper bound threshold. Valid values are in the [-10000.0, 10000.0] range. If mode is set to ignore, then this value has no effect. Optional. Default is 1.0.
combination.technique String The technique for combining scores. Valid values are arithmetic_mean, geometric_mean, and harmonic_mean. Optional. Default is arithmetic_mean. z_score supports only arithmetic_mean.
combination.parameters.weights Array of floating-point values Specifies the weights to use for each query. Valid values are in the [0.0, 1.0] range and signify decimal percentages. The closer the weight is to 1.0, the more weight is given to a query. The number of values in the weights array must equal the number of queries. The sum of the values in the array must equal 1.0. Optional. If not provided, all queries are given equal weight.
tag String The processor’s identifier. Optional.
description String A description of the processor. Optional.
ignore_failure Boolean For this processor, this value is ignored. If the processor fails, the pipeline always fails and returns an error.

Example

The following example demonstrates using a search pipeline with a normalization-processor.

For a comprehensive example, follow the Getting started with semantic and hybrid search.

Creating a search pipeline

The following request creates a search pipeline containing a normalization-processor that uses the min_max normalization technique and the arithmetic_mean combination technique. The combination technique assigns a weight of 30% to the first query and 70% to the second query:

PUT /_search/pipeline/nlp-search-pipeline
{
  "description": "Post processor for hybrid search",
  "phase_results_processors": [
    {
      "normalization-processor": {
        "normalization": {
          "technique": "min_max"
        },
        "combination": {
          "technique": "arithmetic_mean",
          "parameters": {
            "weights": [
              0.3,
              0.7
            ]
          }
        }
      }
    }
  ]
}

The following example demonstrates using the lower_bounds and upper_bounds parameters with the min_max normalization technique. It omits the weights parameter in the combination technique, causing the queries to be weighted equally by default. In this example, the lower_bounds parameter is used to set different lower bounds for each query in a hybrid search, and the upper_bounds parameter is used to set different upper bounds. For the first query, a lower bound of 0.5 is applied and an upper bound of 0.8 is clipped. For the second query, both the lower bound and the upper bound are ignored. This allows for fine-tuning of the normalization process for each individual query in a hybrid search:

PUT /_search/pipeline/nlp-search-pipeline
{
  "description": "Post processor for hybrid search",
  "phase_results_processors": [
    {
      "normalization-processor": {
        "normalization": {
          "technique": "min_max",
          "parameters": {
            "lower_bounds": [
                {
                  "mode": "apply",
                  "min_score": 0.5
                },
                {
                  "mode": "ignore"
                }
              ],
            "upper_bounds": [
              {
                "mode": "clip",
                "max_score": 0.8
              },
              {
                "mode": "ignore"
              }
            ]
          }
        },
        "combination": {
          "technique": "arithmetic_mean"
        }
      }
    }
  ]
}

Using a search pipeline

Provide the query clauses that you want to combine in a hybrid query and apply the search pipeline created in the previous section so that the scores are combined using the chosen techniques:

GET /my-nlp-index/_search?search_pipeline=nlp-search-pipeline
{
  "_source": {
    "exclude": [
      "passage_embedding"
    ]
  },
  "query": {
    "hybrid": {
      "queries": [
        {
          "match": {
            "text": {
              "query": "horse"
            }
          }
        },
        {
          "neural": {
            "passage_embedding": {
              "query_text": "wild west",
              "model_id": "aVeif4oB5Vm0Tdw8zYO2",
              "k": 5
            }
          }
        }
      ]
    }
  }
}

For more information about setting up hybrid search, see Hybrid search.

Search tuning recommendations

To improve search relevance, we recommend increasing the sample size.

If the hybrid query does not return some expected results, it may be because the subqueries return too few documents. The normalization-processor only transforms the results returned by each subquery; it does not perform any additional sampling. During our experiments, we used nDCG@10 to measure quality of information retrieval depending on the number of documents returned (the size). We have found that a size in the [100, 200] range works best for datasets of up to 10M documents. We do not recommend increasing the size beyond the recommended values because higher size values do not improve search relevance but increase search latency.