Skip to content

Commit b09475a

Browse files
authored
Merge branch 'main' into ld/api-endpoint-for-image-crops
2 parents 5bcac47 + 31c745b commit b09475a

8 files changed

Lines changed: 120 additions & 21 deletions

File tree

build.sbt

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,8 @@ Global / concurrentRestrictions := Seq(
7878

7979
val awsSdkVersion = "1.12.470"
8080
val awsSdkV2Version = "2.42.25"
81-
val elastic4sVersion = "8.18.2"
81+
val elastic4sVersion = "8.19.1"
82+
val awsKclVersion = "3.4.3"
8283
val okHttpVersion = "3.12.1"
8384

8485
val bbcBuildProcess: Boolean = System.getenv().asScala.get("BUILD_ORG").contains("bbc")
@@ -177,19 +178,15 @@ lazy val thrall = playProject("thrall", 9002)
177178
pipelineStages := Seq(digest, gzip),
178179
libraryDependencies ++= Seq(
179180
"org.codehaus.groovy" % "groovy-json" % "3.0.7",
180-
// TODO upgrading kcl to v3? check if you can remove avro override below
181-
"software.amazon.kinesis" % "amazon-kinesis-client" % "2.6.1",
181+
"software.amazon.kinesis" % "amazon-kinesis-client" % awsKclVersion,
182182
// explicit dependencies on kinesis and dynamodb to upgrade the versions used by kcl
183183
"software.amazon.awssdk" % "kinesis" % awsSdkV2Version,
184184
"software.amazon.awssdk" % "dynamodb" % awsSdkV2Version,
185185
"com.gu" %% "kcl-pekko-stream" % "0.1.2",
186186
"org.testcontainers" % "elasticsearch" % "1.19.2" % Test,
187187
"com.google.protobuf" % "protobuf-java" % "3.19.6"
188188
),
189-
// amazon-kinesis-client 2.6.0 brings in a critically vulnerable version of apache avro,
190-
// but we cannot upgrade amazon-kinesis-client further without performing the v2->v3 upgrade https://docs.aws.amazon.com/streams/latest/dev/kcl-migration-from-2-3.html
191189
dependencyOverrides ++= Seq(
192-
"org.apache.avro" % "avro" % "1.11.4",
193190
"org.apache.pekko" %% "pekko-stream" % "1.0.3"
194191
)
195192
)
@@ -199,17 +196,12 @@ lazy val usage = playProject("usage", 9009).settings(
199196
"com.gu" %% "content-api-client-default" % "32.0.0",
200197
"com.gu" %% "content-api-client-aws" % "0.7.6",
201198
"io.reactivex" %% "rxscala" % "0.27.0",
202-
// amazon-kinesis-client brings in a critical vulnerability warning through apache avro, resolved in versions 1.11.4 and 1.12.0.
203-
// updating amazon-kinesis-client? check if the override below can be removed
204-
"software.amazon.kinesis" % "amazon-kinesis-client" % "3.0.2",
199+
"software.amazon.kinesis" % "amazon-kinesis-client" % awsKclVersion,
205200
// explicit dependencies on kinesis and dynamodb to upgrade the versions used by kcl
206201
"software.amazon.awssdk" % "kinesis" % awsSdkV2Version,
207202
"software.amazon.awssdk" % "dynamodb" % awsSdkV2Version,
208203
"com.google.protobuf" % "protobuf-java" % "3.19.6"
209204
),
210-
dependencyOverrides ++= Seq(
211-
"org.apache.avro" % "avro" % "1.11.4",
212-
)
213205
)
214206

215207
lazy val scripts = project("scripts")

kahuna/public/js/search/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,7 @@ search.config(['$stateProvider', '$urlMatcherFactoryProvider',
179179
'until',
180180
'orderBy',
181181
'useAISearch',
182+
'vecWeight',
182183
'dateField',
183184
'takenSince',
184185
'takenUntil',

kahuna/public/js/search/query.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,10 @@ query.controller('SearchQueryCtrl', [
8282
ctrl.shouldDisplayAISearchOption = getFeatureSwitchActive("enable-ai-search");
8383
if (!ctrl.shouldDisplayAISearchOption) {
8484
ctrl.useAISearch = false;
85+
ctrl.vecWeight = undefined;
8586
} else {
8687
ctrl.useAISearch = ($stateParams.useAISearch === 'true' || $stateParams.useAISearch === true) ? true : false;
88+
ctrl.vecWeight = $stateParams.vecWeight;
8789
}
8890

8991
//--react - angular interop events--
@@ -455,7 +457,8 @@ query.controller('SearchQueryCtrl', [
455457
if (ctrl.useAISearch) {
456458
$state.go('search.results', {
457459
...ctrl.filter,
458-
useAISearch: true
460+
useAISearch: true,
461+
vecWeight: ctrl.vecWeight
459462
});
460463
} else {
461464
$state.go('search.results', {...ctrl.filter, useAISearch: null});

kahuna/public/js/search/results.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -578,6 +578,7 @@ results.controller('SearchResultsCtrl', [
578578
length: length,
579579
orderBy: orderBy,
580580
useAISearch: $stateParams.useAISearch,
581+
vecWeight: $stateParams.vecWeight,
581582
hasRightsAcquired: $stateParams.hasRightsAcquired,
582583
hasCrops: $stateParams.hasCrops,
583584
syndicationStatus: $stateParams.syndicationStatus,

kahuna/public/js/services/api/media-api.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ mediaApi.factory('mediaApi',
4242
payType, uploadedBy, offset, length, orderBy,
4343
takenSince, takenUntil,
4444
modifiedSince, modifiedUntil, hasRightsAcquired, hasCrops,
45-
syndicationStatus, countAll, persisted, useAISearch} = {}) {
45+
syndicationStatus, countAll, persisted, useAISearch, vecWeight} = {}) {
4646
return root.follow('search', {
4747
q: query,
4848
since: since,
@@ -65,7 +65,8 @@ mediaApi.factory('mediaApi',
6565
syndicationStatus: syndicationStatus,
6666
countAll,
6767
persisted,
68-
useAISearch: maybeStringToBoolean(useAISearch)
68+
useAISearch: maybeStringToBoolean(useAISearch),
69+
vecWeight: vecWeight
6970
}).get();
7071
}
7172

media-api/app/controllers/MediaApi.scala

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,8 @@ class MediaApi(
8888
"syndicationStatus",
8989
"countAll",
9090
"persisted",
91-
"useAISearch"
91+
"useAISearch",
92+
"vecWeight"
9293
).mkString(",")
9394

9495
private val searchLinkHref = s"${config.rootUri}/images{?$searchParamList}"
@@ -618,7 +619,7 @@ class MediaApi(
618619
} yield searchResults
619620
}
620621

621-
def semanticSearchByText(query: String, k: Int): Future[SearchResults] = {
622+
def semanticSearchByText(query: String, k: Int, vecWeight: Option[Double]): Future[SearchResults] = {
622623
// Normalise key so that "Dogs" and "dogs " share a cache entry.
623624
val cacheKey = query.trim.toLowerCase
624625

@@ -630,21 +631,30 @@ class MediaApi(
630631
logger.info(markers, s"AI search embedding cache miss query=$query")
631632
}
632633

634+
val weight = vecWeight.getOrElse(1.0)
635+
633636
// cache.get(key) is atomic: if two requests race on the same key, only one
634637
// load fires and both callers receive the same Future.
635638
val embeddingFuture = embeddingCache.get(cacheKey)
636639

640+
logger.info(markers, s"vecWeight for query '$query' is $weight")
637641
for {
638642
embedding <- embeddingFuture
639-
searchResults <- elasticSearch.knnSearch(embedding, k = k, numCandidates = Math.max(k * 2, 100))
643+
searchResults <- elasticSearch.hybridSearch(
644+
query = query,
645+
queryEmbedding = embedding,
646+
k = k,
647+
numCandidates = Math.max(k * 2, 100),
648+
vecWeight = weight,
649+
)
640650
} yield searchResults
641651
}
642652

643-
def performAiSearchAndRespond(query: String): Future[Result] = {
653+
def performAiSearchAndRespond(query: String, vecWeight: Option[Double]): Future[Result] = {
644654
val k = config.aiSearchResultLimit
645655
val searchResultsFuture = parseAiSearchMode(query) match {
646656
case SimilarSearch(imageId) => semanticSearchByImage(imageId, k)
647-
case TextSearch(textQuery) => semanticSearchByText(textQuery, k)
657+
case TextSearch(textQuery) => semanticSearchByText(textQuery, k, vecWeight)
648658
}
649659

650660
searchResultsFuture.map(aiSearchResponseFromResults)
@@ -656,7 +666,7 @@ class MediaApi(
656666
case _ if _searchParams.length == 0 =>
657667
emptyAiSearchResponse
658668
case Some(q) if !q.isBlank =>
659-
performAiSearchAndRespond(q)
669+
performAiSearchAndRespond(q, _searchParams.vecWeight)
660670
// Empty queries do not make sense for AI search as we can
661671
// only rank results once we have a meaningful vector to compare with.
662672
// So return 0 results if the query was empty.

media-api/app/lib/elasticsearch/ElasticSearch.scala

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import com.gu.mediaservice.lib.metrics.FutureSyntax
1111
import com.gu.mediaservice.model.{Agencies, Agency, AwaitingReviewForSyndication, Image}
1212
import com.sksamuel.elastic4s.ElasticDsl
1313
import com.sksamuel.elastic4s.ElasticDsl._
14+
import com.sksamuel.elastic4s.requests.common.Operator.And
1415
import com.sksamuel.elastic4s.requests.get.{GetRequest, GetResponse}
1516
import com.sksamuel.elastic4s.requests.script.{Script, ScriptField}
1617
import com.sksamuel.elastic4s.requests.searches._
@@ -19,6 +20,9 @@ import com.sksamuel.elastic4s.requests.searches.aggs.responses.Aggregations
1920
import com.sksamuel.elastic4s.requests.searches.aggs.responses.bucket.{DateHistogram, Terms}
2021
import com.sksamuel.elastic4s.requests.searches.queries.Query
2122
import 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}
2226
import lib.querysyntax.{HierarchyField, Match, Parser, Phrase}
2327
import lib.{MediaApiConfig, MediaApiMetrics, SupplierUsageSummary}
2428
import 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

media-api/app/lib/elasticsearch/ElasticSearchModel.scala

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ case class SearchParams(
8686
printUsageFilters: Option[PrintUsageFilters] = None,
8787
shouldFlagGraphicImages: Boolean = false,
8888
useAISearch: Option[Boolean] = None,
89+
vecWeight: Option[Double] = None
8990
)
9091

9192
case class InvalidUriParams(message: String) extends Throwable
@@ -115,6 +116,8 @@ object SearchParams {
115116

116117
// TODO: return descriptive 400 error if invalid
117118
def parseIntFromQuery(s: String): Option[Int] = Try(s.toInt).toOption
119+
def parseBoundedDoubleFromQuery(s: String): Option[Double] =
120+
Try(s.toDouble).toOption.filter(d => !d.isNaN && !d.isInfinity && d >= 0.0 && d <= 1.0)
118121
def parsePayTypeFromQuery(s: String): Option[PayType.Value] = PayType.create(s)
119122
def parseBooleanFromQuery(s: String): Option[Boolean] = Try(s.toBoolean).toOption
120123
def parseSyndicationStatus(s: String): Option[SyndicationStatus] = Some(SyndicationStatus(s))
@@ -175,6 +178,7 @@ object SearchParams {
175178
printUsageFilters,
176179
shouldFlagGraphicImages = false,
177180
request.getQueryString("useAISearch") flatMap parseBooleanFromQuery,
181+
request.getQueryString("vecWeight") flatMap parseBoundedDoubleFromQuery,
178182
)
179183
}
180184

0 commit comments

Comments
 (0)