Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
266 changes: 140 additions & 126 deletions protogen/gen/opencloud/services/search/v0/search.pb.go

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions protogen/gen/opencloud/services/search/v0/search.swagger.json
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,11 @@
"metricKind": {
"$ref": "#/definitions/v0MetricKind",
"description": "Optional. When set, this aggregation is a scalar metric over `field`\nrather than a bucket aggregation; the corresponding AggregationResult\ncarries `value` instead of `buckets`."
},
"geohashPrecision": {
"type": "integer",
"format": "int32",
"description": "Optional. When \u003e 0, this is a geohash-grid aggregation over `field` (which\nmust resolve to a geo-point field) at the given precision (1-12). Buckets\ncarry the geohash cell as key and its doc count."
}
}
},
Expand Down
4 changes: 4 additions & 0 deletions protogen/proto/opencloud/services/search/v0/search.proto
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,10 @@ message AggregationOption {
// rather than a bucket aggregation; the corresponding AggregationResult
// carries `value` instead of `buckets`.
MetricKind metric_kind = 5 [(google.api.field_behavior) = OPTIONAL];
// Optional. When > 0, this is a geohash-grid aggregation over `field` (which
// must resolve to a geo-point field) at the given precision (1-12). Buckets
// carry the geohash cell as key and its doc count.
int32 geohash_precision = 6 [(google.api.field_behavior) = OPTIONAL];
}

enum MetricKind {
Expand Down
3 changes: 3 additions & 0 deletions services/graph/pkg/service/v0/searchquery.go
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,9 @@ func libregraphAggregationsToSearch(in []libregraph.AggregationOption) []*search
if a.LibreGraphMetricDefinition != nil {
agg.MetricKind = metricKindFromLibregraph(a.LibreGraphMetricDefinition.Kind)
}
if a.LibreGraphGeohashPrecision != nil {
agg.GeohashPrecision = *a.LibreGraphGeohashPrecision
}
out = append(out, agg)
}
return out
Expand Down
9 changes: 9 additions & 0 deletions services/search/pkg/bleve/backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,15 @@ func newBleveFacetRequest(agg *searchService.AggregationOption) (*bleve.FacetReq
if size <= 0 {
size = defaultFacetSize
}
// Geohash: terms facet on the precomputed geohash-prefix sibling field of
// the requested precision (bleve has no native geohash-grid aggregation).
if p := int(agg.GetGeohashPrecision()); p > 0 {
field, ok := searchQuery.ResolveGeohashField(agg.GetField(), p)
if !ok {
return nil, fmt.Errorf("geohash aggregation on non-geo field %q", agg.GetField())
}
return bleve.NewFacetRequest(field, size), nil
}
fr := bleve.NewFacetRequest(agg.GetField(), size)
ranges := aggregationRanges(agg)
if rangesAreDates(ranges) {
Expand Down
168 changes: 168 additions & 0 deletions services/search/pkg/bleve/testdata/mapping.golden.json
Original file line number Diff line number Diff line change
Expand Up @@ -820,6 +820,174 @@
}
}
},
"location_geohash_1": {
"enabled": true,
"dynamic": true,
"fields": [
{
"type": "text",
"analyzer": "keyword",
"store": true,
"index": true,
"include_term_vectors": true,
"docvalues": true
}
]
},
"location_geohash_10": {
"enabled": true,
"dynamic": true,
"fields": [
{
"type": "text",
"analyzer": "keyword",
"store": true,
"index": true,
"include_term_vectors": true,
"docvalues": true
}
]
},
"location_geohash_11": {
"enabled": true,
"dynamic": true,
"fields": [
{
"type": "text",
"analyzer": "keyword",
"store": true,
"index": true,
"include_term_vectors": true,
"docvalues": true
}
]
},
"location_geohash_12": {
"enabled": true,
"dynamic": true,
"fields": [
{
"type": "text",
"analyzer": "keyword",
"store": true,
"index": true,
"include_term_vectors": true,
"docvalues": true
}
]
},
"location_geohash_2": {
"enabled": true,
"dynamic": true,
"fields": [
{
"type": "text",
"analyzer": "keyword",
"store": true,
"index": true,
"include_term_vectors": true,
"docvalues": true
}
]
},
"location_geohash_3": {
"enabled": true,
"dynamic": true,
"fields": [
{
"type": "text",
"analyzer": "keyword",
"store": true,
"index": true,
"include_term_vectors": true,
"docvalues": true
}
]
},
"location_geohash_4": {
"enabled": true,
"dynamic": true,
"fields": [
{
"type": "text",
"analyzer": "keyword",
"store": true,
"index": true,
"include_term_vectors": true,
"docvalues": true
}
]
},
"location_geohash_5": {
"enabled": true,
"dynamic": true,
"fields": [
{
"type": "text",
"analyzer": "keyword",
"store": true,
"index": true,
"include_term_vectors": true,
"docvalues": true
}
]
},
"location_geohash_6": {
"enabled": true,
"dynamic": true,
"fields": [
{
"type": "text",
"analyzer": "keyword",
"store": true,
"index": true,
"include_term_vectors": true,
"docvalues": true
}
]
},
"location_geohash_7": {
"enabled": true,
"dynamic": true,
"fields": [
{
"type": "text",
"analyzer": "keyword",
"store": true,
"index": true,
"include_term_vectors": true,
"docvalues": true
}
]
},
"location_geohash_8": {
"enabled": true,
"dynamic": true,
"fields": [
{
"type": "text",
"analyzer": "keyword",
"store": true,
"index": true,
"include_term_vectors": true,
"docvalues": true
}
]
},
"location_geohash_9": {
"enabled": true,
"dynamic": true,
"fields": [
{
"type": "text",
"analyzer": "keyword",
"store": true,
"index": true,
"include_term_vectors": true,
"docvalues": true
}
]
},
"location_geopoint": {
"enabled": true,
"dynamic": true,
Expand Down
7 changes: 7 additions & 0 deletions services/search/pkg/mapping/bleve.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,13 @@ func buildBleveDocMapping(t reflect.Type, overrides map[string]FieldOpts, prefix
}
doc.AddSubDocumentMapping(fi.Name, subDoc)
doc.AddFieldMappingsAt(fi.Name+GeopointSuffix, bleve.NewGeoPointFieldMapping())
// Geohash prefix siblings power the terms-based geohash aggregation
// (bleve has no native geohash-grid).
for p := 1; p <= MaxGeohashPrecision; p++ {
gh := bleve.NewKeywordFieldMapping()
gh.IncludeInAll = false
doc.AddFieldMappingsAt(GeohashField(fi.Name, p), gh)
}
return nil
}

Expand Down
95 changes: 94 additions & 1 deletion services/search/pkg/mapping/geo.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package mapping

import "strings"
import (
"strconv"
"strings"
)

// GeopointSuffix is appended to a field's name to produce the sibling key
// that carries the geo_point / bleve-geopoint representation of the
Expand Down Expand Up @@ -49,3 +52,93 @@ func addGeopointSibling(m map[string]any, dottedPath string) {
}
parent[leaf+GeopointSuffix] = map[string]any{"lat": lat, "lon": lon}
}

// GeohashSuffix + a precision produce the sibling keyword field carrying the
// geohash prefix of a geopoint at that precision (e.g. "location_geohash_6").
// bleve has no native geohash-grid aggregation, so a terms aggregation on the
// field of the requested precision reproduces it. OpenSearch runs geohash_grid
// on the _geopoint field directly and ignores these.
const GeohashSuffix = "_geohash_"

// MaxGeohashPrecision is the finest geohash length indexed as a sibling field.
const MaxGeohashPrecision = 12

// GeohashField returns the sibling field name carrying base's geohash at the
// given precision, e.g. GeohashField("location", 6) == "location_geohash_6".
func GeohashField(base string, precision int) string {
return base + GeohashSuffix + strconv.Itoa(precision)
}

const geohashBase32 = "0123456789bcdefghjkmnpqrstuvwxyz"

// encodeGeohash returns the standard geohash of (lat, lon) at the given length,
// matching Lucene/OpenSearch so both backends bucket points into the same cells.
func encodeGeohash(lat, lon float64, precision int) string {
latMin, latMax := -90.0, 90.0
lonMin, lonMax := -180.0, 180.0
var b strings.Builder
even := true
bit, ch := 0, 0
for b.Len() < precision {
if even {
mid := (lonMin + lonMax) / 2
if lon >= mid {
ch |= 1 << (4 - bit)
lonMin = mid
} else {
lonMax = mid
}
} else {
mid := (latMin + latMax) / 2
if lat >= mid {
ch |= 1 << (4 - bit)
latMin = mid
} else {
latMax = mid
}
}
even = !even
if bit < 4 {
bit++
} else {
b.WriteByte(geohashBase32[ch])
bit, ch = 0, 0
}
}
return b.String()
}

// addGeohashSiblings writes, for each geopoint override, the geohash prefix of
// the point at every precision 1..MaxGeohashPrecision under the suffixed keys.
func addGeohashSiblings(m map[string]any, overrides map[string]FieldOpts) {
for key, opts := range overrides {
if opts.Type == TypeGeopoint {
addGeohashSibling(m, key)
}
}
}

func addGeohashSibling(m map[string]any, dottedPath string) {
parts := strings.Split(dottedPath, ".")
parent := m
for _, p := range parts[:len(parts)-1] {
next, ok := parent[p].(map[string]any)
if !ok {
return
}
parent = next
}
leaf := parts[len(parts)-1]
obj, ok := parent[leaf].(map[string]any)
if !ok {
return
}
lon, hasLon := obj["longitude"].(float64)
lat, hasLat := obj["latitude"].(float64)
if !hasLon || !hasLat {
return
}
for p := 1; p <= MaxGeohashPrecision; p++ {
parent[GeohashField(leaf, p)] = encodeGeohash(lat, lon, p)
}
}
39 changes: 39 additions & 0 deletions services/search/pkg/mapping/geo_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,3 +141,42 @@ var _ = Describe("PrepareForIndex geopoint", func() {
Expect(endGp["lon"]).To(Equal(elon))
})
})

var _ = Describe("geohash", func() {
// Wikipedia's canonical reference point.
const refLat, refLon = 57.64911, 10.40744

It("encodes the canonical geohash vector (matches Lucene/OpenSearch)", func() {
Expect(encodeGeohash(refLat, refLon, 11)).To(Equal("u4pruydqqvj"))
})

It("is prefix-consistent across precisions", func() {
full := encodeGeohash(refLat, refLon, 11)
for p := 1; p <= 11; p++ {
Expect(encodeGeohash(refLat, refLon, p)).To(Equal(full[:p]),
"precision %d must be the %d-char prefix", p, p)
}
})

It("adds geohash prefix siblings for a geopoint at every precision", func() {
type geoDoc struct {
Location *struct {
Longitude *float64 `json:"longitude,omitempty"`
Latitude *float64 `json:"latitude,omitempty"`
} `json:"location,omitempty"`
}
lon, lat := refLon, refLat
doc := geoDoc{Location: &struct {
Longitude *float64 `json:"longitude,omitempty"`
Latitude *float64 `json:"latitude,omitempty"`
}{Longitude: &lon, Latitude: &lat}}

m, err := PrepareForIndex(doc, map[string]FieldOpts{"location": {Type: TypeGeopoint}})
Expect(err).ToNot(HaveOccurred())
for p := 1; p <= MaxGeohashPrecision; p++ {
Expect(m[GeohashField("location", p)]).To(Equal(encodeGeohash(lat, lon, p)),
"location_geohash_%d", p)
}
Expect(m[GeohashField("location", 5)]).To(Equal("u4pru"))
})
})
6 changes: 6 additions & 0 deletions services/search/pkg/mapping/opensearch.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,12 @@ func buildOpenSearchProperties(t reflect.Type, overrides map[string]FieldOpts, p
}
props[fi.Name] = map[string]any{"properties": subProps}
props[fi.Name+GeopointSuffix] = map[string]any{"type": "geo_point"}
// Mirror the bleve geohash prefix siblings so the document shape
// stays consistent across backends (OpenSearch aggregates via
// geohash_grid on _geopoint and does not query these).
for p := 1; p <= MaxGeohashPrecision; p++ {
props[GeohashField(fi.Name, p)] = map[string]any{"type": "keyword"}
}
return nil
}

Expand Down
Loading