Skip to content

Commit 22d8cf0

Browse files
authored
Merge pull request #580 from lonvia/location-bias-and-query-structure
Rework location bias and query builder in general
2 parents db6fa7c + 912b83e commit 22d8cf0

12 files changed

+149
-444
lines changed

README.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,10 +122,18 @@ http://localhost:2322/api?q=berlin
122122
http://localhost:2322/api?q=berlin&lon=10&lat=52
123123
```
124124

125-
Increase this bias (range is 0.1 to 10, default is 1.6)
125+
There are two optional parameters to influence the location bias. 'zoom'
126+
describes the radius around the center to focus on. This is a number that
127+
should correspond roughly to the map zoom parameter of a corresponding map.
128+
The default is `zoom=16`.
129+
130+
The `location_bias_scale` describes how much the prominence of a result should
131+
still be taken into account. Sensible values go from 0.0 (ignore prominence
132+
almost completely) to 1.0 (prominence has approximately the same influence).
133+
The default is 0.2.
126134

127135
```
128-
http://localhost:2322/api?q=berlin&lon=10&lat=52&location_bias_scale=2
136+
http://localhost:2322/api?q=berlin&lon=10&lat=52&zoom=12&location_bias_scale=0.1
129137
```
130138

131139
#### Reverse geocode a coordinate

src/main/java/de/komoot/photon/elasticsearch/IndexMapping.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,8 @@ public void putMapping(Client client, String indexName, String indexType) {
3939
public IndexMapping addLanguages(String[] languages) {
4040
// define collector json strings
4141
String copyToCollectorString = "{\"type\":\"text\",\"index\":false,\"copy_to\":[\"collector.{lang}\"]}";
42-
String nameToCollectorString = "{\"type\":\"text\",\"index\":false,\"fields\":{\"ngrams\":{\"type\":\"text\",\"analyzer\":\"index_ngram\"},\"raw\":{\"type\":\"text\",\"analyzer\":\"index_raw\"}},\"copy_to\":[\"collector.{lang}\"]}";
43-
String collectorString = "{\"type\":\"text\",\"index\":false,\"fields\":{\"ngrams\":{\"type\":\"text\",\"analyzer\":\"index_ngram\"},\"raw\":{\"type\":\"text\",\"analyzer\":\"index_raw\"}},\"copy_to\":[\"collector.{lang}\"]}";
42+
String nameToCollectorString = "{\"type\":\"text\",\"index\":false,\"fields\":{\"ngrams\":{\"type\":\"text\",\"analyzer\":\"index_ngram\"},\"raw\":{\"type\":\"text\",\"analyzer\":\"index_raw\",\"search_analyzer\":\"search_raw\"}},\"copy_to\":[\"collector.{lang}\"]}";
43+
String collectorString = "{\"type\":\"text\",\"index\":false,\"fields\":{\"ngrams\":{\"type\":\"text\",\"analyzer\":\"index_ngram\"},\"raw\":{\"type\":\"text\",\"analyzer\":\"index_raw\",\"search_analyzer\":\"search_raw\"}},\"copy_to\":[\"collector.{lang}\"]}";
4444

4545
JSONObject placeObject = mappings.optJSONObject("place");
4646
JSONObject propertiesObject = placeObject == null ? null : placeObject.optJSONObject("properties");

src/main/java/de/komoot/photon/query/PhotonQueryBuilder.java

Lines changed: 80 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,7 @@
1111
import org.elasticsearch.index.query.functionscore.FunctionScoreQueryBuilder;
1212
import org.elasticsearch.index.query.functionscore.FunctionScoreQueryBuilder.FilterFunctionBuilder;
1313
import org.elasticsearch.index.query.functionscore.ScoreFunctionBuilders;
14-
import org.elasticsearch.index.query.functionscore.ScriptScoreFunctionBuilder;
15-
import org.elasticsearch.script.Script;
16-
import org.elasticsearch.script.ScriptType;
14+
import org.elasticsearch.index.query.functionscore.WeightBuilder;
1715

1816
import java.util.*;
1917

@@ -34,6 +32,8 @@
3432
* Created by Sachin Dole on 2/12/2015.
3533
*/
3634
public class PhotonQueryBuilder {
35+
private static final String[] ALT_NAMES = new String[]{"alt", "int", "loc", "old", "reg", "housename"};
36+
3737
private FunctionScoreQueryBuilder finalQueryWithoutTagFilterBuilder;
3838

3939
private BoolQueryBuilder queryBuilderForTopLevelFilter;
@@ -50,27 +50,25 @@ public class PhotonQueryBuilder {
5050

5151
protected ArrayList<FilterFunctionBuilder> alFilterFunction4QueryBuilder = new ArrayList<>(1);
5252

53-
protected BoolQueryBuilder query4QueryBuilder;
54-
5553

5654
private PhotonQueryBuilder(String query, String language, List<String> languages, boolean lenient) {
57-
query4QueryBuilder = QueryBuilders.boolQuery();
55+
BoolQueryBuilder query4QueryBuilder = QueryBuilders.boolQuery();
5856

57+
// 1. All terms of the quey must be contained in the place record somehow. Be more lenient on second try.
58+
QueryBuilder collectorQuery;
5959
if (lenient) {
60-
BoolQueryBuilder builder = QueryBuilders.boolQuery()
60+
collectorQuery = QueryBuilders.boolQuery()
6161
.should(QueryBuilders.matchQuery("collector.default", query)
62-
.fuzziness(Fuzziness.ONE)
63-
.prefixLength(2)
64-
.analyzer("search_ngram")
65-
.minimumShouldMatch("-1"))
62+
.fuzziness(Fuzziness.ONE)
63+
.prefixLength(2)
64+
.analyzer("search_ngram")
65+
.minimumShouldMatch("-1"))
6666
.should(QueryBuilders.matchQuery(String.format("collector.%s.ngrams", language), query)
67-
.fuzziness(Fuzziness.ONE)
68-
.prefixLength(2)
69-
.analyzer("search_ngram")
70-
.minimumShouldMatch("-1"))
67+
.fuzziness(Fuzziness.ONE)
68+
.prefixLength(2)
69+
.analyzer("search_ngram")
70+
.minimumShouldMatch("-1"))
7171
.minimumShouldMatch("1");
72-
73-
query4QueryBuilder.must(builder);
7472
} else {
7573
MultiMatchQueryBuilder builder =
7674
QueryBuilders.multiMatchQuery(query).field("collector.default", 1.0f).type(MultiMatchQueryBuilder.Type.CROSS_FIELDS).prefixLength(2).analyzer("search_ngram").minimumShouldMatch("100%");
@@ -79,31 +77,65 @@ private PhotonQueryBuilder(String query, String language, List<String> languages
7977
builder.field(String.format("collector.%s.ngrams", lang), lang.equals(language) ? 1.0f : 0.6f);
8078
}
8179

82-
query4QueryBuilder.must(builder);
80+
collectorQuery = builder;
8381
}
8482

85-
query4QueryBuilder
86-
.should(QueryBuilders.matchQuery(String.format("name.%s.raw", language), query).boost(200)
87-
.analyzer("search_raw"))
88-
.should(QueryBuilders.matchQuery(String.format("collector.%s.raw", language), query).boost(100)
89-
.analyzer("search_raw"));
83+
query4QueryBuilder.must(collectorQuery);
84+
85+
// 2. Prefer records that have the full names in. For address records with housenumbers this is the main
86+
// filter creterion because they have no name. Therefore boost the score in this case.
87+
MultiMatchQueryBuilder hnrQuery = QueryBuilders.multiMatchQuery(query)
88+
.field("collector.default.raw", 1.0f)
89+
.type(MultiMatchQueryBuilder.Type.BEST_FIELDS);
90+
91+
for (String lang : languages) {
92+
hnrQuery.field(String.format("collector.%s.raw", lang), lang.equals(language) ? 1.0f : 0.6f);
93+
}
94+
95+
query4QueryBuilder.should(QueryBuilders.functionScoreQuery(hnrQuery.boost(0.3f), new FilterFunctionBuilder[]{
96+
new FilterFunctionBuilder(QueryBuilders.matchQuery("housenumber", query).analyzer("standard"), new WeightBuilder().setWeight(10f))
97+
}));
98+
99+
// 3. Either the name or housenumber must be in the query terms.
100+
String defLang = "default".equals(language) ? languages.get(0) : language;
101+
MultiMatchQueryBuilder nameNgramQuery = QueryBuilders.multiMatchQuery(query)
102+
.type(MultiMatchQueryBuilder.Type.BEST_FIELDS)
103+
.fuzziness(lenient ? Fuzziness.ONE : Fuzziness.ZERO)
104+
.analyzer("search_ngram");
105+
106+
for (String lang: languages) {
107+
nameNgramQuery.field(String.format("name.%s.ngrams", lang), lang.equals(defLang) ? 1.0f : 0.4f);
108+
}
90109

91-
// this is former general-score, now inline
92-
String strCode = "double score = 1 + doc['importance'].value * 100; score";
93-
ScriptScoreFunctionBuilder functionBuilder4QueryBuilder =
94-
ScoreFunctionBuilders.scriptFunction(new Script(ScriptType.INLINE, "painless", strCode, new HashMap<String, Object>()));
110+
for (String alt: ALT_NAMES) {
111+
nameNgramQuery.field(String.format("name.%s.raw", alt), 0.4f);
112+
}
113+
114+
if (query.indexOf(',') < 0 && query.indexOf(' ') < 0) {
115+
query4QueryBuilder.must(nameNgramQuery.boost(2f));
116+
} else {
117+
query4QueryBuilder.must(QueryBuilders.boolQuery()
118+
.should(nameNgramQuery)
119+
.should(QueryBuilders.matchQuery("housenumber", query).analyzer("standard"))
120+
.minimumShouldMatch("1"));
121+
}
122+
123+
// 4. Rerank results for having the full name in the default language.
124+
query4QueryBuilder
125+
.should(QueryBuilders.matchQuery(String.format("name.%s.raw", language), query));
95126

96-
alFilterFunction4QueryBuilder.add(new FilterFunctionBuilder(functionBuilder4QueryBuilder));
97127

98-
finalQueryWithoutTagFilterBuilder = new FunctionScoreQueryBuilder(query4QueryBuilder, alFilterFunction4QueryBuilder.toArray(new FilterFunctionBuilder[0]))
99-
.boostMode(CombineFunction.MULTIPLY).scoreMode(ScoreMode.MULTIPLY);
128+
// Weigh the resulting score by importance. Use a linear scale function that ensures that the weight
129+
// never drops to 0 and cancels out the ES score.
130+
finalQueryWithoutTagFilterBuilder = QueryBuilders.functionScoreQuery(query4QueryBuilder, new FilterFunctionBuilder[]{
131+
new FilterFunctionBuilder(ScoreFunctionBuilders.linearDecayFunction("importance", "1.0", "0.6"))
132+
});
100133

101-
// @formatter:off
134+
// Filter for later: records that have a housenumber and no name must only appear when the housenumber matches.
102135
queryBuilderForTopLevelFilter = QueryBuilders.boolQuery()
103136
.should(QueryBuilders.boolQuery().mustNot(QueryBuilders.existsQuery("housenumber")))
104137
.should(QueryBuilders.matchQuery("housenumber", query).analyzer("standard"))
105138
.should(QueryBuilders.existsQuery(String.format("name.%s.raw", language)));
106-
// @formatter:on
107139

108140
state = State.PLAIN;
109141
}
@@ -120,21 +152,27 @@ public static PhotonQueryBuilder builder(String query, String language, List<Str
120152
return new PhotonQueryBuilder(query, language, languages, lenient);
121153
}
122154

123-
public PhotonQueryBuilder withLocationBias(Point point, double scale) {
124-
if (point == null) return this;
155+
public PhotonQueryBuilder withLocationBias(Point point, double scale, int zoom) {
156+
if (point == null || zoom < 4) return this;
157+
158+
if (zoom > 18) {
159+
zoom = 18;
160+
}
161+
double radius = (1 << (18 - zoom)) * 0.25;
162+
163+
if (scale <= 0.0) {
164+
scale = 0.0000001;
165+
}
166+
125167
Map<String, Object> params = newHashMap();
126168
params.put("lon", point.getX());
127169
params.put("lat", point.getY());
128170

129-
scale = Math.abs(scale);
130-
String strCode = "double dist = doc['coordinate'].planeDistance(params.lat, params.lon); " +
131-
"double score = 0.1 + " + scale + " / (1.0 + dist * 0.001 / 10.0); " +
132-
"score";
133-
ScriptScoreFunctionBuilder builder = ScoreFunctionBuilders.scriptFunction(new Script(ScriptType.INLINE, "painless", strCode, params));
134-
alFilterFunction4QueryBuilder.add(new FilterFunctionBuilder(builder));
135171
finalQueryWithoutTagFilterBuilder =
136-
new FunctionScoreQueryBuilder(query4QueryBuilder, alFilterFunction4QueryBuilder.toArray(new FilterFunctionBuilder[0]))
137-
.boostMode(CombineFunction.MULTIPLY);
172+
QueryBuilders.functionScoreQuery(finalQueryWithoutTagFilterBuilder, new FilterFunctionBuilder[] {
173+
new FilterFunctionBuilder(ScoreFunctionBuilders.exponentialDecayFunction("coordinate", params, radius + "km", radius / 10 + "km", 0.8)),
174+
new FilterFunctionBuilder(ScoreFunctionBuilders.linearDecayFunction("importance", "1.0", scale))
175+
}).boostMode(CombineFunction.MULTIPLY).scoreMode(ScoreMode.MAX);
138176
return this;
139177
}
140178

src/main/java/de/komoot/photon/query/PhotonRequest.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ public class PhotonRequest implements Serializable {
1818
private Point locationForBias;
1919
private String language;
2020
private final double scale;
21+
private final int zoom;
2122
private Envelope bbox;
2223
private boolean debug;
2324

@@ -30,11 +31,12 @@ public class PhotonRequest implements Serializable {
3031
private Map<String, Set<String>> excludeTagValues;
3132

3233

33-
public PhotonRequest(String query, int limit, Envelope bbox, Point locationForBias, double scale, String language, boolean debug) {
34+
public PhotonRequest(String query, int limit, Envelope bbox, Point locationForBias, double scale, int zoom, String language, boolean debug) {
3435
this.query = query;
3536
this.limit = limit;
3637
this.locationForBias = locationForBias;
3738
this.scale = scale;
39+
this.zoom = zoom;
3840
this.language = language;
3941
this.bbox = bbox;
4042
this.debug = debug;
@@ -60,6 +62,10 @@ public double getScaleForBias() {
6062
return scale;
6163
}
6264

65+
public int getZoomForBias() {
66+
return zoom;
67+
}
68+
6369
public String getLanguage() {
6470
return language;
6571
}

src/main/java/de/komoot/photon/query/PhotonRequestFactory.java

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ public class PhotonRequestFactory {
1919
private final BoundingBoxParamConverter bboxParamConverter;
2020

2121
private static final HashSet<String> REQUEST_QUERY_PARAMS = new HashSet<>(Arrays.asList("lang", "q", "lon", "lat",
22-
"limit", "osm_tag", "location_bias_scale", "bbox", "debug"));
22+
"limit", "osm_tag", "location_bias_scale", "bbox", "debug", "zoom"));
2323

2424
public PhotonRequestFactory(List<String> supportedLanguages, String defaultLanguage) {
2525
this.languageResolver = new RequestLanguageResolver(supportedLanguages, defaultLanguage);
@@ -47,8 +47,7 @@ public PhotonRequest create(Request webRequest) throws BadRequestException {
4747
Point locationForBias = optionalLocationParamConverter.apply(webRequest);
4848
Envelope bbox = bboxParamConverter.apply(webRequest);
4949

50-
// don't use too high default value, see #306
51-
double scale = 1.6;
50+
double scale = 0.2;
5251
String scaleStr = webRequest.queryParams("location_bias_scale");
5352
if (scaleStr != null && !scaleStr.isEmpty())
5453
try {
@@ -57,9 +56,19 @@ public PhotonRequest create(Request webRequest) throws BadRequestException {
5756
throw new BadRequestException(400, "invalid parameter 'location_bias_scale' must be a number");
5857
}
5958

59+
int zoom = 16;
60+
String zoomStr = webRequest.queryParams("zoom");
61+
if (zoomStr != null && !zoomStr.isEmpty()) {
62+
try {
63+
zoom = Integer.parseInt(zoomStr);
64+
} catch (NumberFormatException e) {
65+
throw new BadRequestException(400, "Invalid parameter 'zoom'. Must be a number.");
66+
}
67+
}
68+
6069
boolean debug = webRequest.queryParams("debug") != null;
6170

62-
PhotonRequest request = new PhotonRequest(query, limit, bbox, locationForBias, scale, language, debug);
71+
PhotonRequest request = new PhotonRequest(query, limit, bbox, locationForBias, scale, zoom, language, debug);
6372

6473
QueryParamsMap tagFiltersQueryMap = webRequest.queryMap("osm_tag");
6574
if (new CheckIfFilteredRequest().execute(tagFiltersQueryMap)) {

src/main/java/de/komoot/photon/searcher/PhotonRequestHandler.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ public PhotonQueryBuilder buildQuery(PhotonRequest photonRequest, boolean lenien
5959
withoutKeys(photonRequest.notKeys()).
6060
withoutValues(photonRequest.notValues()).
6161
withTagsNotValues(photonRequest.tagNotValues()).
62-
withLocationBias(photonRequest.getLocationForBias(), photonRequest.getScaleForBias()).
62+
withLocationBias(photonRequest.getLocationForBias(), photonRequest.getScaleForBias(), photonRequest.getZoomForBias()).
6363
withBoundingBox(photonRequest.getBbox());
6464
}
6565
}

src/test/java/de/komoot/photon/ApiIntegrationTest.java

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,8 @@ public class ApiIntegrationTest extends ESBaseTester {
2727
public void setUp() throws Exception {
2828
setUpES();
2929
Importer instance = makeImporter();
30-
instance.add(createDoc(13.38886, 52.51704, 1000, 1000, "place", "city"));
31-
instance.add(createDoc(13.39026, 52.54714, 1001, 1001, "place", "town"));
30+
instance.add(createDoc(13.38886, 52.51704, 1000, 1000, "place", "city").importance(0.6));
31+
instance.add(createDoc(13.39026, 52.54714, 1001, 1001, "place", "town").importance(0.3));
3232
instance.finish();
3333
refresh();
3434
}
@@ -112,6 +112,27 @@ public void testApiWithLocationBias() throws Exception {
112112
assertEquals("berlin", properties.getString("name"));
113113
}
114114

115+
/**
116+
* Search with large location bias
117+
*/
118+
@Test
119+
public void testApiWithLargerLocationBias() throws Exception {
120+
App.main(new String[]{"-cluster", TEST_CLUSTER_NAME, "-listen-port", Integer.toString(LISTEN_PORT), "-transport-addresses", "127.0.0.1"});
121+
awaitInitialization();
122+
HttpURLConnection connection = (HttpURLConnection) new URL("http://127.0.0.1:" + port() + "/api?q=berlin&limit=1&lat=52.54714&lon=13.39026&zoom=12&location_bias_scale=0.6")
123+
.openConnection();
124+
JSONObject json = new JSONObject(
125+
new BufferedReader(new InputStreamReader(connection.getInputStream())).lines().collect(Collectors.joining("\n")));
126+
JSONArray features = json.getJSONArray("features");
127+
assertEquals(1, features.length());
128+
JSONObject feature = features.getJSONObject(0);
129+
JSONObject properties = feature.getJSONObject("properties");
130+
assertEquals("W", properties.getString("osm_type"));
131+
assertEquals("place", properties.getString("osm_key"));
132+
assertEquals("city", properties.getString("osm_value"));
133+
assertEquals("berlin", properties.getString("name"));
134+
}
135+
115136
/**
116137
* Reverse geocode test
117138
*/

0 commit comments

Comments
 (0)