Kaynağa Gözat

Add the `field_value_factor` function to the function_score query

The `field_value_factor` function uses the value of a field in the
document to influence the score.

A query that looks like:
{
  "query": {
    "function_score": {
      "query": {"match": { "body": "foo" }},
      "functions": [
        {
          "field_value_factor": {
            "field": "popularity",
            "factor": 1.1,
            "modifier": "square"
          }
        }
      ],
      "score_mode": "max",
      "boost_mode": "sum"
    }
  }
}

Would have the score modified by:

square(1.1 * doc['popularity'].value)

Closes #5519
Lee Hinman 11 yıl önce
ebeveyn
işleme
8fbd1bdd48

+ 3 - 5
docs/reference/mapping/fields/boost-field.asciidoc

@@ -56,8 +56,8 @@ any field the document:
                 }
             },
             "functions": [{
-                "script_score": { <2>
-                    "script": "doc['my_boost_field'].value"
+                "field_value_factor": { <2>
+                    "field": "my_boost_field"
                 }
             }],
             "score_mode": "multiply"
@@ -66,7 +66,5 @@ any field the document:
 }
 --------------------------------------------------
 <1> The original query, now wrapped in a `function_score` query.
-<2> This script returns the value in `my_boost_field`, which is then
+<2> This function returns the value in `my_boost_field`, which is then
     multiplied by the query `_score` for each document.
-
-

+ 40 - 2
docs/reference/query-dsl/queries/function-score-query.asciidoc

@@ -246,6 +246,46 @@ In contrast to the normal and exponential decay, this function actually
 sets the score to 0 if the field value exceeds twice the user given
 scale value.
 
+===== Field Value factor
+The `field_value_factor` function allows you to use a field from a document to
+influence the score. It's similar to using the `script_score` function, however,
+it avoids the overhead of scripting. If used on a multi-valued field, only the
+first value of the field is used in calculations.
+
+As an example, imagine you have a document indexed with a numeric `popularity`
+field and wish in influence the score of a document with this field, an example
+doing so would look like:
+
+[source,js]
+--------------------------------------------------
+"field_value_factor": {
+  "field": "popularity",
+  "factor": 1.2,
+  "modifier": "sqrt"
+}
+--------------------------------------------------
+
+Which will translate into the following forumla for scoring:
+
+`sqrt(1.2 * doc['popularity'].value)`
+
+There are a number of options for the `field_value_factor` function:
+
+[cols="<,<",options="header",]
+|=======================================================================
+| Parameter |Description
+|`field` |Field to be extracted from the document.
+|`factor` |Optional factor to multiply the field value with, defaults to 1.
+|`modifier` |Modifier to apply to the field value, can be one of: `none`, `log`,
+ `log1p`, `log2p`, `ln`, `ln1p`, `ln2p`, `square`, `sqrt`, or `reciprocal`.
+ Defaults to `none`.
+|=======================================================================
+
+Keep in mind that taking the log() of 0, or the square root of a negative number
+is an illegal operation, and an exception will be thrown. Be sure to limit the
+values of the field with a range filter to avoid this, or use `log1p` and
+`ln1p`.
+
 ==== Detailed example
 
 Suppose you are searching for a hotel in a certain town. Your budget is
@@ -487,5 +527,3 @@ becomes:
     "score_mode": "first"
 }
 --------------------------------------------------
-
-

+ 159 - 0
src/main/java/org/elasticsearch/common/lucene/search/function/FieldValueFactorFunction.java

@@ -0,0 +1,159 @@
+/*
+ * Licensed to Elasticsearch under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.elasticsearch.common.lucene.search.function;
+
+import org.apache.lucene.index.AtomicReaderContext;
+import org.apache.lucene.search.Explanation;
+import org.elasticsearch.ElasticsearchException;
+import org.elasticsearch.index.fielddata.DoubleValues;
+import org.elasticsearch.index.fielddata.IndexNumericFieldData;
+
+import java.util.Locale;
+
+/**
+ * A function_score function that multiplies the score with the value of a
+ * field from the document, optionally multiplying the field by a factor first,
+ * and applying a modification (log, ln, sqrt, square, etc) afterwards.
+ */
+public class FieldValueFactorFunction extends ScoreFunction {
+    private final String field;
+    private final float boostFactor;
+    private final Modifier modifier;
+    private final IndexNumericFieldData indexFieldData;
+    private DoubleValues values;
+
+    public FieldValueFactorFunction(String field, float boostFactor, Modifier modifierType, IndexNumericFieldData indexFieldData) {
+        super(CombineFunction.MULT);
+        this.field = field;
+        this.boostFactor = boostFactor;
+        this.modifier = modifierType;
+        this.indexFieldData = indexFieldData;
+    }
+
+    @Override
+    public void setNextReader(AtomicReaderContext context) {
+        this.values = this.indexFieldData.load(context).getDoubleValues();
+    }
+
+    @Override
+    public double score(int docId, float subQueryScore) {
+        final int numValues = this.values.setDocument(docId);
+        if (numValues > 0) {
+            double val = this.values.nextValue() * boostFactor;
+            double result = modifier.apply(val);
+            if (Double.isNaN(result) || Double.isInfinite(result)) {
+                throw new ElasticsearchException("Result of field modification [" + modifier.toString() +
+                        "(" + val + ")] must be a number");
+            }
+            return result;
+        } else {
+            throw new ElasticsearchException("Missing value for field [" + field + "]");
+        }
+    }
+
+    @Override
+    public Explanation explainScore(int docId, Explanation subQueryExpl) {
+        Explanation exp = new Explanation();
+        String modifierStr = modifier != null ? modifier.toString() : "";
+        double score = score(docId, subQueryExpl.getValue());
+        exp.setValue(CombineFunction.toFloat(score));
+        exp.setDescription("field value function: " +
+                modifierStr + "(" + "doc['" + field + "'].value * factor=" + boostFactor + ")");
+        exp.addDetail(subQueryExpl);
+        return exp;
+    }
+
+    /**
+     * The Type class encapsulates the modification types that can be applied
+     * to the score/value product.
+     */
+    public enum Modifier {
+        NONE {
+            @Override
+            public double apply(double n) {
+                return n;
+            }
+        },
+        LOG {
+            @Override
+            public double apply(double n) {
+                return Math.log10(n);
+            }
+        },
+        LOG1P {
+            @Override
+            public double apply(double n) {
+                return Math.log10(n + 1);
+            }
+        },
+        LOG2P {
+            @Override
+            public double apply(double n) {
+                return Math.log10(n + 2);
+            }
+        },
+        LN {
+            @Override
+            public double apply(double n) {
+                return Math.log(n);
+            }
+        },
+        LN1P {
+            @Override
+            public double apply(double n) {
+                return Math.log1p(n);
+            }
+        },
+        LN2P {
+            @Override
+            public double apply(double n) {
+                return Math.log1p(n + 1);
+            }
+        },
+        SQUARE {
+            @Override
+            public double apply(double n) {
+                return Math.pow(n, 2);
+            }
+        },
+        SQRT {
+            @Override
+            public double apply(double n) {
+                return Math.sqrt(n);
+            }
+        },
+        RECIPROCAL {
+            @Override
+            public double apply(double n) {
+                return 1.0 / n;
+            }
+        };
+
+        public abstract double apply(double n);
+
+        @Override
+        public String toString() {
+            if (this == NONE) {
+                return "";
+            }
+            return super.toString().toLowerCase(Locale.ROOT);
+        }
+    }
+}

+ 2 - 0
src/main/java/org/elasticsearch/index/query/functionscore/FunctionScoreModule.java

@@ -23,6 +23,7 @@ import org.elasticsearch.common.inject.AbstractModule;
 import org.elasticsearch.common.inject.multibindings.Multibinder;
 import org.elasticsearch.index.query.functionscore.exp.ExponentialDecayFunctionParser;
 import org.elasticsearch.index.query.functionscore.factor.FactorParser;
+import org.elasticsearch.index.query.functionscore.fieldvaluefactor.FieldValueFactorFunctionParser;
 import org.elasticsearch.index.query.functionscore.gauss.GaussDecayFunctionParser;
 import org.elasticsearch.index.query.functionscore.lin.LinearDecayFunctionParser;
 import org.elasticsearch.index.query.functionscore.random.RandomScoreFunctionParser;
@@ -44,6 +45,7 @@ public class FunctionScoreModule extends AbstractModule {
         registerParser(LinearDecayFunctionParser.class);
         registerParser(ExponentialDecayFunctionParser.class);
         registerParser(RandomScoreFunctionParser.class);
+        registerParser(FieldValueFactorFunctionParser.class);
     }
 
     public void registerParser(Class<? extends ScoreFunctionParser> parser) {

+ 5 - 0
src/main/java/org/elasticsearch/index/query/functionscore/ScoreFunctionBuilders.java

@@ -21,6 +21,7 @@ package org.elasticsearch.index.query.functionscore;
 
 import org.elasticsearch.index.query.functionscore.exp.ExponentialDecayFunctionBuilder;
 import org.elasticsearch.index.query.functionscore.factor.FactorBuilder;
+import org.elasticsearch.index.query.functionscore.fieldvaluefactor.FieldValueFactorFunctionBuilder;
 import org.elasticsearch.index.query.functionscore.gauss.GaussDecayFunctionBuilder;
 import org.elasticsearch.index.query.functionscore.lin.LinearDecayFunctionBuilder;
 import org.elasticsearch.index.query.functionscore.random.RandomScoreFunctionBuilder;
@@ -77,4 +78,8 @@ public class ScoreFunctionBuilders {
     public static RandomScoreFunctionBuilder randomFunction(long seed) {
         return (new RandomScoreFunctionBuilder()).seed(seed);
     }
+
+    public static FieldValueFactorFunctionBuilder fieldValueFactorFunction(String fieldName) {
+        return new FieldValueFactorFunctionBuilder(fieldName);
+    }
 }

+ 74 - 0
src/main/java/org/elasticsearch/index/query/functionscore/fieldvaluefactor/FieldValueFactorFunctionBuilder.java

@@ -0,0 +1,74 @@
+/*
+ * Licensed to Elasticsearch under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.elasticsearch.index.query.functionscore.fieldvaluefactor;
+
+import org.elasticsearch.common.lucene.search.function.FieldValueFactorFunction;
+import org.elasticsearch.common.xcontent.XContentBuilder;
+import org.elasticsearch.index.query.functionscore.ScoreFunctionBuilder;
+
+import java.io.IOException;
+import java.util.Locale;
+
+/**
+ * Builder to construct {@code field_value_factor} functions for a function
+ * score query.
+ */
+public class FieldValueFactorFunctionBuilder implements ScoreFunctionBuilder {
+    private String field = null;
+    private Float factor = null;
+    private FieldValueFactorFunction.Modifier modifier = null;
+
+    public FieldValueFactorFunctionBuilder(String fieldName) {
+        this.field = fieldName;
+    }
+
+    @Override
+    public String getName() {
+        return FieldValueFactorFunctionParser.NAMES[0];
+    }
+
+    public FieldValueFactorFunctionBuilder factor(float boostFactor) {
+        this.factor = boostFactor;
+        return this;
+    }
+
+    public FieldValueFactorFunctionBuilder modifier(FieldValueFactorFunction.Modifier modifier) {
+        this.modifier = modifier;
+        return this;
+    }
+
+    @Override
+    public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
+        builder.startObject(getName());
+        if (field != null) {
+            builder.field("field", field);
+        }
+
+        if (factor != null) {
+            builder.field("factor", factor);
+        }
+
+        if (modifier != null) {
+            builder.field("modifier", modifier.toString().toLowerCase(Locale.ROOT));
+        }
+        builder.endObject();
+        return builder;
+    }
+}

+ 93 - 0
src/main/java/org/elasticsearch/index/query/functionscore/fieldvaluefactor/FieldValueFactorFunctionParser.java

@@ -0,0 +1,93 @@
+/*
+ * Licensed to Elasticsearch under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.elasticsearch.index.query.functionscore.fieldvaluefactor;
+
+import org.elasticsearch.ElasticsearchException;
+import org.elasticsearch.common.lucene.search.function.FieldValueFactorFunction;
+import org.elasticsearch.common.lucene.search.function.ScoreFunction;
+import org.elasticsearch.common.xcontent.XContentParser;
+import org.elasticsearch.index.fielddata.IndexNumericFieldData;
+import org.elasticsearch.index.mapper.FieldMapper;
+import org.elasticsearch.index.query.QueryParseContext;
+import org.elasticsearch.index.query.QueryParsingException;
+import org.elasticsearch.index.query.functionscore.ScoreFunctionParser;
+import org.elasticsearch.search.internal.SearchContext;
+
+import java.io.IOException;
+import java.util.Locale;
+
+/**
+ * Parses out a function_score function that looks like:
+ *
+ * <pre>
+ *     {
+ *         "field_value_factor": {
+ *             "field": "myfield",
+ *             "factor": 1.5,
+ *             "modifier": "square"
+ *         }
+ *     }
+ * </pre>
+ */
+public class FieldValueFactorFunctionParser implements ScoreFunctionParser {
+    public static String[] NAMES = { "field_value_factor", "fieldValueFactor" };
+
+    @Override
+    public ScoreFunction parse(QueryParseContext parseContext, XContentParser parser) throws IOException, QueryParsingException {
+
+        String currentFieldName = null;
+        String field = null;
+        float boostFactor = 1;
+        FieldValueFactorFunction.Modifier modifier = FieldValueFactorFunction.Modifier.NONE;
+        XContentParser.Token token;
+        while ((token = parser.nextToken()) != XContentParser.Token.END_OBJECT) {
+            if (token == XContentParser.Token.FIELD_NAME) {
+                currentFieldName = parser.currentName();
+            } else if (token.isValue()) {
+                if ("field".equals(currentFieldName)) {
+                    field = parser.text();
+                } else if ("factor".equals(currentFieldName)) {
+                    boostFactor = parser.floatValue();
+                } else if ("modifier".equals(currentFieldName)) {
+                    modifier = FieldValueFactorFunction.Modifier.valueOf(parser.text().toUpperCase(Locale.ROOT));
+                } else {
+                    throw new QueryParsingException(parseContext.index(), NAMES[0] + " query does not support [" + currentFieldName + "]");
+                }
+            }
+        }
+
+        if (field == null) {
+            throw new QueryParsingException(parseContext.index(), "[" + NAMES[0] + "] required field 'field' missing");
+        }
+
+        SearchContext searchContext = SearchContext.current();
+        FieldMapper mapper = searchContext.mapperService().smartNameFieldMapper(field);
+        if (mapper == null) {
+            throw new ElasticsearchException("Unable to find a field mapper for field [" + field + "]");
+        }
+        return new FieldValueFactorFunction(field, boostFactor, modifier,
+                (IndexNumericFieldData)searchContext.fieldData().getForField(mapper));
+    }
+
+    @Override
+    public String[] getNames() {
+        return NAMES;
+    }
+}

+ 106 - 0
src/test/java/org/elasticsearch/search/functionscore/FunctionScoreFieldValueTests.java

@@ -0,0 +1,106 @@
+/*
+ * Licensed to Elasticsearch under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.elasticsearch.search.functionscore;
+
+import org.elasticsearch.action.search.SearchPhaseExecutionException;
+import org.elasticsearch.action.search.SearchResponse;
+import org.elasticsearch.common.lucene.search.function.FieldValueFactorFunction;
+import org.elasticsearch.test.ElasticsearchIntegrationTest;
+import org.junit.Test;
+
+import java.io.IOException;
+
+import static org.elasticsearch.common.xcontent.XContentFactory.jsonBuilder;
+import static org.elasticsearch.index.query.QueryBuilders.functionScoreQuery;
+import static org.elasticsearch.index.query.QueryBuilders.matchAllQuery;
+import static org.elasticsearch.index.query.QueryBuilders.simpleQueryString;
+import static org.elasticsearch.index.query.functionscore.ScoreFunctionBuilders.fieldValueFactorFunction;
+import static org.elasticsearch.test.hamcrest.ElasticsearchAssertions.*;
+
+/**
+ * Tests for the {@code field_value_factor} function in a function_score query.
+ */
+public class FunctionScoreFieldValueTests extends ElasticsearchIntegrationTest {
+
+    @Test
+    public void testFieldValueFactor() throws IOException {
+        assertAcked(prepareCreate("test").addMapping(
+                "type1",
+                jsonBuilder()
+                        .startObject()
+                        .startObject("type1")
+                        .startObject("properties")
+                        .startObject("test")
+                        .field("type", randomFrom(new String[]{"short", "float", "long", "integer", "double"}))
+                        .endObject()
+                        .startObject("body")
+                        .field("type", "string")
+                        .endObject()
+                        .endObject()
+                        .endObject()
+                        .endObject()).get());
+        ensureYellow();
+
+        client().prepareIndex("test", "type1", "1").setSource("test", 5, "body", "foo").get();
+        client().prepareIndex("test", "type1", "2").setSource("test", 17, "body", "foo").get();
+        client().prepareIndex("test", "type1", "3").setSource("body", "bar").get();
+
+        refresh();
+
+        // document 2 scores higher because 17 > 5
+        SearchResponse response = client().prepareSearch("test")
+                .setExplain(randomBoolean())
+                .setQuery(functionScoreQuery(simpleQueryString("foo"), fieldValueFactorFunction("test")))
+                .get();
+        assertOrderedSearchHits(response, "2", "1");
+
+        // document 1 scores higher because 1/5 > 1/17
+        response = client().prepareSearch("test")
+                .setExplain(randomBoolean())
+                .setQuery(functionScoreQuery(simpleQueryString("foo"),
+                        fieldValueFactorFunction("test").modifier(FieldValueFactorFunction.Modifier.RECIPROCAL)))
+                .get();
+        assertOrderedSearchHits(response, "1", "2");
+
+        // doc 3 doesn't have a "test" field, so an exception will be thrown
+        try {
+            response = client().prepareSearch("test")
+                    .setExplain(randomBoolean())
+                    .setQuery(functionScoreQuery(matchAllQuery(), fieldValueFactorFunction("test")))
+                    .get();
+            assertFailures(response);
+        } catch (SearchPhaseExecutionException e) {
+            // We are expecting an exception, because 3 has no field
+        }
+
+        // n divided by 0 is infinity, which should provoke an exception.
+        try {
+            response = client().prepareSearch("test")
+                    .setExplain(randomBoolean())
+                    .setQuery(functionScoreQuery(simpleQueryString("foo"),
+                            fieldValueFactorFunction("test").modifier(FieldValueFactorFunction.Modifier.RECIPROCAL).factor(0)))
+                    .get();
+            assertFailures(response);
+        } catch (SearchPhaseExecutionException e) {
+            // This is fine, the query will throw an exception if executed
+            // locally, instead of just having failures
+        }
+    }
+}