@@ -11,6 +11,7 @@ import com.gu.mediaservice.lib.metrics.FutureSyntax
1111import com .gu .mediaservice .model .{Agencies , Agency , AwaitingReviewForSyndication , Image }
1212import com .sksamuel .elastic4s .ElasticDsl
1313import com .sksamuel .elastic4s .ElasticDsl ._
14+ import com .sksamuel .elastic4s .requests .common .Operator .And
1415import com .sksamuel .elastic4s .requests .get .{GetRequest , GetResponse }
1516import com .sksamuel .elastic4s .requests .script .{Script , ScriptField }
1617import com .sksamuel .elastic4s .requests .searches ._
@@ -19,6 +20,9 @@ import com.sksamuel.elastic4s.requests.searches.aggs.responses.Aggregations
1920import com .sksamuel .elastic4s .requests .searches .aggs .responses .bucket .{DateHistogram , Terms }
2021import com .sksamuel .elastic4s .requests .searches .queries .Query
2122import com .sksamuel .elastic4s .requests .searches .knn .Knn
23+ import com .sksamuel .elastic4s .requests .searches .queries .compound .BoolQuery
24+ import com .sksamuel .elastic4s .requests .searches .queries .matches .MultiMatchQueryBuilderType .BEST_FIELDS
25+ import com .sksamuel .elastic4s .requests .searches .queries .matches .{FieldWithOptionalBoost , MultiMatchQuery }
2226import lib .querysyntax .{HierarchyField , Match , Parser , Phrase }
2327import lib .{MediaApiConfig , MediaApiMetrics , SupplierUsageSummary }
2428import play .api .libs .json .{JsError , JsObject , JsSuccess , Json }
@@ -191,6 +195,89 @@ class ElasticSearch(
191195 }
192196 }
193197
198+ private def createMultiMatchQuery (query : String , boost : Option [Double ] = None ): MultiMatchQuery =
199+ MultiMatchQuery (
200+ text = query,
201+ fields = matchFields.map(field => FieldWithOptionalBoost (field, None )),
202+ `type` = Some (BEST_FIELDS ),
203+ fuzziness = Some (" AUTO" ),
204+ maxExpansions = Some (50 ),
205+ operator = Some (And ),
206+ prefixLength = Some (1 ),
207+ boost = boost
208+ )
209+
210+ // BM25 scores are unbounded [0,inf] and typically much larger in magnitude
211+ // than cosine similarity (knn). So we get the max BM25 score for the query and use that to calculate
212+ // the scaling factor for the lexical part of the query, so that BM25 and knn scores are both between 0-1 scale
213+ // and can be effectively combined in a hybrid query.
214+ private def fetchMaxBm25Score (query : String )(implicit ex : ExecutionContext , logMarker : LogMarker ): Future [Double ] = {
215+ val maxScoreRequest = ElasticDsl .search(imagesCurrentAlias)
216+ .query(createMultiMatchQuery(query))
217+
218+ executeAndLog(withSearchQueryTimeout(maxScoreRequest), " max BM25 score" ).map { r =>
219+ logger.info(logMarker, s " Max BM25 score for query ' $query' is ${r.result.hits.maxScore}" )
220+ if (r.result.hits.hits.isEmpty) 1.0 else r.result.hits.maxScore
221+ }
222+ }
223+
224+ private def makeHybridSearchRequest (
225+ query : String ,
226+ queryEmbedding : List [Double ],
227+ k : Int ,
228+ numCandidates : Int ,
229+ vecWeight : Double ,
230+ maxScore : Double
231+ )(implicit logMarker : LogMarker ): SearchRequest = {
232+ val knn = Knn (" embedding.cohereEmbedV4.image" )
233+ .queryVector(queryEmbedding)
234+ .k(k)
235+ .numCandidates(numCandidates)
236+ .boost(if (vecWeight > 0.0 ) 1.0 else 0.0 )
237+
238+ val lexicalWeight = 1.0 - vecWeight
239+
240+ // KNN results are in [0,1], but BM25 scores are unbounded and typically much
241+ // larger than cosine similarity, so we need to apply a scaling factor to the
242+ // BM25 score to bring it to the same range as the cosine similarity.
243+ val scalingFactor = if (maxScore > 0.0 ) 1.0 / maxScore else 1.0
244+
245+ // We want to apply only one boost if we can help it, so we scale the
246+ // multi_match boost to be in line with the max_score and the desired
247+ // lexical_weight/vec_weight balance
248+ val multiMatchBoost = if (vecWeight > 0.0 ) (lexicalWeight / vecWeight) * scalingFactor else 1.0
249+
250+ logger.info(logMarker, s " Scaling factor for BM25 score is $scalingFactor, multi-match boost is $multiMatchBoost" )
251+
252+ val multiMatchQuery = createMultiMatchQuery(query, boost = Some (multiMatchBoost))
253+
254+ ElasticDsl .search(imagesCurrentAlias)
255+ .bool(BoolQuery ().should(Seq (multiMatchQuery, knn)))
256+ .size(k)
257+ }
258+
259+ def hybridSearch (
260+ query : String ,
261+ queryEmbedding : List [Float ],
262+ k : Int ,
263+ numCandidates : Int ,
264+ vecWeight : Double ,
265+ )(
266+ implicit ex : ExecutionContext ,
267+ logMarker : LogMarker
268+ ): Future [SearchResults ] = {
269+ val queryEmbeddingDouble : List [Double ] = queryEmbedding.map(_.toDouble)
270+
271+ for {
272+ maxScore <- fetchMaxBm25Score(query)
273+ searchRequest = makeHybridSearchRequest(query, queryEmbeddingDouble, k, numCandidates, vecWeight, maxScore)
274+ result <- executeAndLog(withSearchQueryTimeout(searchRequest), " hybrid search" )
275+ } yield {
276+ val imageHits = result.result.hits.hits.map(resolveHit).toSeq.flatten.map(i => (i.instance.id, i))
277+ SearchResults (hits = imageHits, total = imageHits.length, extraCounts = None )
278+ }
279+ }
280+
194281 def search (params : SearchParams )(implicit ex : ExecutionContext , request : AuthenticatedRequest [AnyContent , Principal ], logMarker : LogMarker = MarkerMap ()): Future [SearchResults ] = {
195282 val query : Query = queryBuilder.makeQuery(params.structuredQuery)
196283
0 commit comments