From 14965b070c8149b801cb01bfdfc114a5032fbfaf Mon Sep 17 00:00:00 2001 From: Alex Dufournet <3389563+alexduf@users.noreply.github.com> Date: Wed, 6 May 2026 15:17:51 +0200 Subject: [PATCH 1/7] WIP: hybrid search --- build.sbt | 2 +- media-api/app/controllers/MediaApi.scala | 9 ++- .../app/lib/elasticsearch/ElasticSearch.scala | 67 +++++++++++++++++++ 3 files changed, 76 insertions(+), 2 deletions(-) diff --git a/build.sbt b/build.sbt index b27ebb4a5b..81cc63429d 100644 --- a/build.sbt +++ b/build.sbt @@ -78,7 +78,7 @@ Global / concurrentRestrictions := Seq( val awsSdkVersion = "1.12.470" val awsSdkV2Version = "2.42.25" -val elastic4sVersion = "8.18.2" +val elastic4sVersion = "8.19.1" val okHttpVersion = "3.12.1" val bbcBuildProcess: Boolean = System.getenv().asScala.get("BUILD_ORG").contains("bbc") diff --git a/media-api/app/controllers/MediaApi.scala b/media-api/app/controllers/MediaApi.scala index 970663fbc4..9ce7a52132 100644 --- a/media-api/app/controllers/MediaApi.scala +++ b/media-api/app/controllers/MediaApi.scala @@ -636,7 +636,14 @@ class MediaApi( for { embedding <- embeddingFuture - searchResults <- elasticSearch.knnSearch(embedding, k = k, numCandidates = Math.max(k * 2, 100)) + searchResults <- elasticSearch.hybridSearch( + query = query, + queryEmbedding = embedding, + k = k, + numCandidates = Math.max(k * 2, 100), + maxScore = 1, // TODO execute first query to get max score + vecWeight = 0.5, // TODO hardcode the actual constant here + ) } yield searchResults } diff --git a/media-api/app/lib/elasticsearch/ElasticSearch.scala b/media-api/app/lib/elasticsearch/ElasticSearch.scala index 8b6b578934..1b7a6f631f 100644 --- a/media-api/app/lib/elasticsearch/ElasticSearch.scala +++ b/media-api/app/lib/elasticsearch/ElasticSearch.scala @@ -11,6 +11,7 @@ import com.gu.mediaservice.lib.metrics.FutureSyntax import com.gu.mediaservice.model.{Agencies, Agency, AwaitingReviewForSyndication, Image} import com.sksamuel.elastic4s.ElasticDsl import com.sksamuel.elastic4s.ElasticDsl._ +import com.sksamuel.elastic4s.requests.common.Operator.And import com.sksamuel.elastic4s.requests.get.{GetRequest, GetResponse} import com.sksamuel.elastic4s.requests.script.{Script, ScriptField} import com.sksamuel.elastic4s.requests.searches._ @@ -19,6 +20,9 @@ import com.sksamuel.elastic4s.requests.searches.aggs.responses.Aggregations import com.sksamuel.elastic4s.requests.searches.aggs.responses.bucket.{DateHistogram, Terms} import com.sksamuel.elastic4s.requests.searches.queries.Query import com.sksamuel.elastic4s.requests.searches.knn.Knn +import com.sksamuel.elastic4s.requests.searches.queries.compound.BoolQuery +import com.sksamuel.elastic4s.requests.searches.queries.matches.MultiMatchQueryBuilderType.BEST_FIELDS +import com.sksamuel.elastic4s.requests.searches.queries.matches.{FieldWithOptionalBoost, MultiMatchQuery} import lib.querysyntax.{HierarchyField, Match, Parser, Phrase} import lib.{MediaApiConfig, MediaApiMetrics, SupplierUsageSummary} import play.api.libs.json.{JsError, JsObject, JsSuccess, Json} @@ -191,6 +195,69 @@ class ElasticSearch( } } + def hybridSearch( + query: String, + queryEmbedding: List[Float], + k: Int, + numCandidates: Int, + maxScore: Double, + vecWeight: Double, + )( + implicit ex: ExecutionContext, + logMarker: LogMarker + ): Future[SearchResults] = { + val queryEmbeddingDouble: List[Double] = queryEmbedding.map(_.toDouble) + val knn = Knn("embedding.cohereEmbedV4.image") + .queryVector(queryEmbeddingDouble) + .k(k) + .numCandidates(numCandidates) + + val multiMatchQuery = MultiMatchQuery( + text = query, + fields = matchFields.map(field => FieldWithOptionalBoost(field, None)), + `type` = Some(BEST_FIELDS), + fuzziness = Some("AUTO"), + maxExpansions = Some(50), + operator = Some(And), + prefixLength = Some(1) + ) + + val scriptParams: Map[String, Any] = Map( + "queryVector" -> queryEmbeddingDouble, + "maxScore" -> maxScore, + "vecWeight" -> vecWeight, + "lexicalWeight" -> (1.0 - vecWeight), + ) + + //language=groovy -- it's actually painless, but that's pretty similar to groovy and this provides syntax highlighting + val script: String = + """// _score reflects lexical relevance but is not pure BM25 + |double scoreVal = _score; + |// Normalise lexical magnitude using approximate max to prevent BM25 signal dominance + |double scoreNorm = (scoreVal / params.maxScore) + 1.0; + | + |// Cosine similarity shifted to positive range. + |// Pre-shifting, negative similarities are rare in practice but allowed. + |double vectorScore = 0; + |if (doc['embedding.cohereEmbedV4.image'].size() > 0) { + | vectorScore = cosineSimilarity(params.queryVector, 'embedding.cohereEmbedV4.image') + 1.0; + |} + | + |return (params.vecWeight * vectorScore) + + | (params.lexicalWeight * scoreNorm); + |""".stripMargin + + val searchRequest = ElasticDsl.search(imagesCurrentAlias) + .bool(BoolQuery().should(Seq(multiMatchQuery, knn))) + .scriptfields(ScriptField("source", Script(script, params = scriptParams))) + .size(k) + + executeAndLog(withSearchQueryTimeout(searchRequest), "hybrid search").map { r => + val imageHits = r.result.hits.hits.map(resolveHit).toSeq.flatten.map(i => (i.instance.id, i)) + SearchResults(hits = imageHits, total = r.result.totalHits, extraCounts = None) + } + } + def search(params: SearchParams)(implicit ex: ExecutionContext, request: AuthenticatedRequest[AnyContent, Principal], logMarker: LogMarker = MarkerMap()): Future[SearchResults] = { val query: Query = queryBuilder.makeQuery(params.structuredQuery) From 9a892530c6092bf9afa6a510d2352b30084f581c Mon Sep 17 00:00:00 2001 From: Ellen Muller Date: Thu, 14 May 2026 12:10:17 +0100 Subject: [PATCH 2/7] move to hybrid search --- media-api/app/controllers/MediaApi.scala | 3 +- .../app/lib/elasticsearch/ElasticSearch.scala | 131 ++++++++++++------ media-api/app/lib/elasticsearch/scratch.txt | 12 ++ 3 files changed, 104 insertions(+), 42 deletions(-) create mode 100644 media-api/app/lib/elasticsearch/scratch.txt diff --git a/media-api/app/controllers/MediaApi.scala b/media-api/app/controllers/MediaApi.scala index 371c84d8b8..64865ed6f7 100644 --- a/media-api/app/controllers/MediaApi.scala +++ b/media-api/app/controllers/MediaApi.scala @@ -641,8 +641,7 @@ class MediaApi( queryEmbedding = embedding, k = k, numCandidates = Math.max(k * 2, 100), - maxScore = 1, // TODO execute first query to get max score - vecWeight = 0.5, // TODO hardcode the actual constant here + vecWeight = 1.0, // TODO hardcode the actual constant here ) } yield searchResults } diff --git a/media-api/app/lib/elasticsearch/ElasticSearch.scala b/media-api/app/lib/elasticsearch/ElasticSearch.scala index 6b4991b563..902f214ccd 100644 --- a/media-api/app/lib/elasticsearch/ElasticSearch.scala +++ b/media-api/app/lib/elasticsearch/ElasticSearch.scala @@ -200,61 +200,112 @@ class ElasticSearch( queryEmbedding: List[Float], k: Int, numCandidates: Int, - maxScore: Double, +// maxScore: Double, vecWeight: Double, )( implicit ex: ExecutionContext, logMarker: LogMarker ): Future[SearchResults] = { + +// TEST ON CEREBRO WITH +// {"query": { +// "bool": { +// "must": [ +// { +// "multi_match": { +// "query": "politics", +// "fields": [ +// "id", +// "source.mimeType", +// "metadata.description", +// "metadata.title", +// "metadata.byline", +// "metadata.source", +// "metadata.credit", +// "metadata.keywords", +// "metadata.subLocation", +// "metadata.city", +// "metadata.state", +// "metadata.country", +// "metadata.suppliersReference", +// "metadata.peopleInImage", +// "metadata.specialInstructions", +// "metadata.englishAnalysedCatchAll", +// "metadata.imageType", +// "userMetadata.labels", +// "identifiers.YOUR_PERSISTENCE_IDENTIFIER", +// "identifiers.derivative-of-media-ids", +// "usageRights.restrictions" +// ], +// "type": "best_fields", +// "fuzziness": "AUTO", +// "max_expansions": 50, +// "operator": "and", +// "prefix_length": 1 +// } +// } +// ] +// } +// } +// } + def maxBM25Score(query: String): Future[Double] = { + + val maxScore = ElasticDsl.search(imagesCurrentAlias) + .query(BoolQuery().must( + MultiMatchQuery( + text = query, + fields = matchFields.map(field => FieldWithOptionalBoost(field, None)), + `type` = Some(BEST_FIELDS), + fuzziness = Some("AUTO"), + maxExpansions = Some(50), + operator = Some(And), + prefixLength = Some(1), +// boost = Some(1.0) + )) + ) + .size(0) // we don't actually need the hits, just the max score + + val maxScoreFuture = executeAndLog(withSearchQueryTimeout(maxScore), "max BM25 score").map { r => + logger.info(logMarker, s"Max BM25 score for query '$query' is ${r.result.hits.maxScore} with total hits ${r.result.totalHits}") + if (r.result.hits.hits.isEmpty) 1.0 else r.result.hits.maxScore + } + maxScoreFuture + } + val queryEmbeddingDouble: List[Double] = queryEmbedding.map(_.toDouble) val knn = Knn("embedding.cohereEmbedV4.image") .queryVector(queryEmbeddingDouble) .k(k) .numCandidates(numCandidates) + .boost(1.0) - val multiMatchQuery = MultiMatchQuery( - text = query, - fields = matchFields.map(field => FieldWithOptionalBoost(field, None)), - `type` = Some(BEST_FIELDS), - fuzziness = Some("AUTO"), - maxExpansions = Some(50), - operator = Some(And), - prefixLength = Some(1) - ) + val lexicalWeight = 1.0 - vecWeight - val scriptParams: Map[String, Any] = Map( - "queryVector" -> queryEmbeddingDouble, - "maxScore" -> maxScore, - "vecWeight" -> vecWeight, - "lexicalWeight" -> (1.0 - vecWeight), - ) + maxBM25Score(query).flatMap { maxScore => + val scalingFactor = 1.0 / maxScore + val multiMatchBoost = lexicalWeight * scalingFactor - //language=groovy -- it's actually painless, but that's pretty similar to groovy and this provides syntax highlighting - val script: String = - """// _score reflects lexical relevance but is not pure BM25 - |double scoreVal = _score; - |// Normalise lexical magnitude using approximate max to prevent BM25 signal dominance - |double scoreNorm = (scoreVal / params.maxScore) + 1.0; - | - |// Cosine similarity shifted to positive range. - |// Pre-shifting, negative similarities are rare in practice but allowed. - |double vectorScore = 0; - |if (doc['embedding.cohereEmbedV4.image'].size() > 0) { - | vectorScore = cosineSimilarity(params.queryVector, 'embedding.cohereEmbedV4.image') + 1.0; - |} - | - |return (params.vecWeight * vectorScore) + - | (params.lexicalWeight * scoreNorm); - |""".stripMargin + logger.info(logMarker, s"Scaling factor for BM25 score is $scalingFactor, multi-match boost is $multiMatchBoost") - val searchRequest = ElasticDsl.search(imagesCurrentAlias) - .bool(BoolQuery().should(Seq(multiMatchQuery, knn))) - .scriptfields(ScriptField("source", Script(script, params = scriptParams))) - .size(k) + val multiMatchQuery = MultiMatchQuery( + text = query, + fields = matchFields.map(field => FieldWithOptionalBoost(field, None)), + `type` = Some(BEST_FIELDS), + fuzziness = Some("AUTO"), + maxExpansions = Some(50), + operator = Some(And), + prefixLength = Some(1), + boost = Some(multiMatchBoost) + ) - executeAndLog(withSearchQueryTimeout(searchRequest), "hybrid search").map { r => - val imageHits = r.result.hits.hits.map(resolveHit).toSeq.flatten.map(i => (i.instance.id, i)) - SearchResults(hits = imageHits, total = r.result.totalHits, extraCounts = None) + val searchRequest = ElasticDsl.search(imagesCurrentAlias) + .bool(BoolQuery().should(Seq(multiMatchQuery, knn))) + .size(k) + + executeAndLog(withSearchQueryTimeout(searchRequest), "hybrid search").map { r => + val imageHits = r.result.hits.hits.map(resolveHit).toSeq.flatten.map(i => (i.instance.id, i)) + SearchResults(hits = imageHits, total = r.result.totalHits, extraCounts = None) + } } } diff --git a/media-api/app/lib/elasticsearch/scratch.txt b/media-api/app/lib/elasticsearch/scratch.txt new file mode 100644 index 0000000000..5970bc4a59 --- /dev/null +++ b/media-api/app/lib/elasticsearch/scratch.txt @@ -0,0 +1,12 @@ +[0.02045091,-0.024293203,0.002695802,0.00023627,-0.009853621,-0.022062195,-0.01512128,0.030614395,-0.017724123,0.01134096,-0.034952465,0.018715683,-0.014005776,-0.025904488,0.0013324078,-0.0070958463,-0.025408708,-0.010659263,-0.042884942,-0.006600067,-0.017972013,0.027639717,0.023673479,-0.029251,-0.0045239893,0.004740893,0.030738339,-0.0027577744,-0.013138161,-0.0028972125,-0.010473345,-0.007467681,0.022310086,-0.045611728,-0.05775833,-0.003392992,0.013200134,-0.018839628,0.0014718459,0.019211462,-0.011774767,-0.0231777,-0.011960684,0.01134096,-0.09717281,0.030366505,-0.009109952,0.025904488,-0.003501444,0.014563528,-0.0069719017,0.03024256,-0.021442471,-0.014377611,-0.017600179,0.00283524,0.014563528,0.01109307,0.007870502,-0.024293203,0.0015338183,-0.038175035,-0.004462017,0.027267883,-0.038670816,0.0413976,0.050817415,-0.00062747113,0.009047979,-0.01636073,0.009915593,0.015988894,-0.0031605954,-0.041149713,0.0076535987,-0.025284763,-0.0038422924,-0.06246824,-0.018095957,0.041645493,-0.014377611,0.028507331,-0.027267883,-0.029374946,-0.0139438035,0.004554976,-0.010287428,-0.047346957,-0.00567048,-0.030738339,-0.028383385,-0.01158885,0.018343847,-0.003392992,-0.01134096,-0.053296313,-0.0047718794,0.027019992,0.04709907,-0.012952244,-0.004833852,0.019335408,-0.007467681,0.017228343,-0.016484674,0.0023859397,0.011217015,-0.0027732674,-0.008614171,0.00189016,0.008800089,-0.028755222,-0.005143714,0.0030056643,0.026400268,0.005825411,0.01134096,0.008738116,0.0231777,0.009977566,-0.016980454,0.030986229,-0.019831186,0.049330078,-0.012828299,-0.0827952,0.016608618,-0.035448246,0.005701466,0.04412439,-0.0020605843,-0.0068789427,-0.01109307,0.0012317026,-0.000042363987,-0.05726255,0.025284763,0.030986229,-0.006104287,0.0014486063,0.030614395,-0.02094669,-0.00066233065,0.008242337,-0.014315638,0.0023549534,0.07734163,-0.001138744,0.014253666,0.01109307,-0.0035324302,0.028755222,-0.0021535428,0.054039985,0.004585962,-0.005825411,-0.02751577,-0.04982586,-0.002990171,0.016980454,0.018467793,-0.049330078,0.08180364,-0.0061662598,-0.062964015,-0.010721236,0.017972013,0.021442471,0.018591737,0.035200357,0.027019992,0.033217236,0.00032535542,-0.0074986676,0.0089860065,-0.008428254,0.010845181,0.013757885,0.02491293,-0.034208797,0.04660329,-0.015431142,0.035200357,-0.024045315,-0.004988783,0.04982586,-0.035448246,0.018219903,-0.019831186,0.021690361,0.026152378,0.02193825,0.019087518,0.040158153,-0.032969348,-0.0027887607,-0.0089860065,-0.028631276,-0.012456465,-0.02491293,0.04585962,-0.015183252,0.011836739,0.035200357,-0.0028817193,-0.027763661,0.016980454,-0.06445136,0.04610751,-0.049082186,0.039910264,0.0062282323,0.0123325195,0.02045091,-0.050321635,-0.035696138,-0.011155043,-0.027763661,0.021194581,0.0033620058,0.00050352624,-0.034456685,0.0030676366,0.04090182,0.03024256,-0.0038887719,-0.0027732674,-0.025408708,0.022310086,0.020326966,0.03222568,0.0005306392,0.0054225903,0.036191914,-0.018219903,-0.022310086,0.010783208,-0.026772102,-0.014749445,0.014005776,0.015431142,0.009171924,-0.039166592,0.035448246,0.05874989,0.014687473,-0.016484674,-0.041149713,-0.042884942,0.0011232508,-0.0070648603,0.017972013,-0.054039985,-0.0438765,-0.03222568,0.007932475] + +{ + "knn": { + "field": "embedding.cohereEmbedV4.image", + "query_vector": [0.02045091,-0.024293203,0.002695802,0.00023627,-0.009853621,-0.022062195,-0.01512128,0.030614395,-0.017724123,0.01134096,-0.034952465,0.018715683,-0.014005776,-0.025904488,0.0013324078,-0.0070958463,-0.025408708,-0.010659263,-0.042884942,-0.006600067,-0.017972013,0.027639717,0.023673479,-0.029251,-0.0045239893,0.004740893,0.030738339,-0.0027577744,-0.013138161,-0.0028972125,-0.010473345,-0.007467681,0.022310086,-0.045611728,-0.05775833,-0.003392992,0.013200134,-0.018839628,0.0014718459,0.019211462,-0.011774767,-0.0231777,-0.011960684,0.01134096,-0.09717281,0.030366505,-0.009109952,0.025904488,-0.003501444,0.014563528,-0.0069719017,0.03024256,-0.021442471,-0.014377611,-0.017600179,0.00283524,0.014563528,0.01109307,0.007870502,-0.024293203,0.0015338183,-0.038175035,-0.004462017,0.027267883,-0.038670816,0.0413976,0.050817415,-0.00062747113,0.009047979,-0.01636073,0.009915593,0.015988894,-0.0031605954,-0.041149713,0.0076535987,-0.025284763,-0.0038422924,-0.06246824,-0.018095957,0.041645493,-0.014377611,0.028507331,-0.027267883,-0.029374946,-0.0139438035,0.004554976,-0.010287428,-0.047346957,-0.00567048,-0.030738339,-0.028383385,-0.01158885,0.018343847,-0.003392992,-0.01134096,-0.053296313,-0.0047718794,0.027019992,0.04709907,-0.012952244,-0.004833852,0.019335408,-0.007467681,0.017228343,-0.016484674,0.0023859397,0.011217015,-0.0027732674,-0.008614171,0.00189016,0.008800089,-0.028755222,-0.005143714,0.0030056643,0.026400268,0.005825411,0.01134096,0.008738116,0.0231777,0.009977566,-0.016980454,0.030986229,-0.019831186,0.049330078,-0.012828299,-0.0827952,0.016608618,-0.035448246,0.005701466,0.04412439,-0.0020605843,-0.0068789427,-0.01109307,0.0012317026,-0.000042363987,-0.05726255,0.025284763,0.030986229,-0.006104287,0.0014486063,0.030614395,-0.02094669,-0.00066233065,0.008242337,-0.014315638,0.0023549534,0.07734163,-0.001138744,0.014253666,0.01109307,-0.0035324302,0.028755222,-0.0021535428,0.054039985,0.004585962,-0.005825411,-0.02751577,-0.04982586,-0.002990171,0.016980454,0.018467793,-0.049330078,0.08180364,-0.0061662598,-0.062964015,-0.010721236,0.017972013,0.021442471,0.018591737,0.035200357,0.027019992,0.033217236,0.00032535542,-0.0074986676,0.0089860065,-0.008428254,0.010845181,0.013757885,0.02491293,-0.034208797,0.04660329,-0.015431142,0.035200357,-0.024045315,-0.004988783,0.04982586,-0.035448246,0.018219903,-0.019831186,0.021690361,0.026152378,0.02193825,0.019087518,0.040158153,-0.032969348,-0.0027887607,-0.0089860065,-0.028631276,-0.012456465,-0.02491293,0.04585962,-0.015183252,0.011836739,0.035200357,-0.0028817193,-0.027763661,0.016980454,-0.06445136,0.04610751,-0.049082186,0.039910264,0.0062282323,0.0123325195,0.02045091,-0.050321635,-0.035696138,-0.011155043,-0.027763661,0.021194581,0.0033620058,0.00050352624,-0.034456685,0.0030676366,0.04090182,0.03024256,-0.0038887719,-0.0027732674,-0.025408708,0.022310086,0.020326966,0.03222568,0.0005306392,0.0054225903,0.036191914,-0.018219903,-0.022310086,0.010783208,-0.026772102,-0.014749445,0.014005776,0.015431142,0.009171924,-0.039166592,0.035448246,0.05874989,0.014687473,-0.016484674,-0.041149713,-0.042884942,0.0011232508,-0.0070648603,0.017972013,-0.054039985,-0.0438765,-0.03222568,0.007932475], + "k": 200, + "num_candidates": 400 + }, + "size": 10, + "timeout": "10s" +} From 0096880dfd19801defc9c8b4dd5598010d5cd60b Mon Sep 17 00:00:00 2001 From: Ellen Muller Date: Thu, 14 May 2026 12:24:14 +0100 Subject: [PATCH 3/7] add comments and handle divide by 0 case --- media-api/app/controllers/MediaApi.scala | 2 +- .../app/lib/elasticsearch/ElasticSearch.scala | 63 +++++-------------- 2 files changed, 16 insertions(+), 49 deletions(-) diff --git a/media-api/app/controllers/MediaApi.scala b/media-api/app/controllers/MediaApi.scala index 64865ed6f7..5172fc1d45 100644 --- a/media-api/app/controllers/MediaApi.scala +++ b/media-api/app/controllers/MediaApi.scala @@ -641,7 +641,7 @@ class MediaApi( queryEmbedding = embedding, k = k, numCandidates = Math.max(k * 2, 100), - vecWeight = 1.0, // TODO hardcode the actual constant here + vecWeight = 0.0, // TODO hardcode the actual constant here ) } yield searchResults } diff --git a/media-api/app/lib/elasticsearch/ElasticSearch.scala b/media-api/app/lib/elasticsearch/ElasticSearch.scala index 902f214ccd..a9ea65ae5d 100644 --- a/media-api/app/lib/elasticsearch/ElasticSearch.scala +++ b/media-api/app/lib/elasticsearch/ElasticSearch.scala @@ -200,56 +200,17 @@ class ElasticSearch( queryEmbedding: List[Float], k: Int, numCandidates: Int, -// maxScore: Double, vecWeight: Double, )( implicit ex: ExecutionContext, logMarker: LogMarker ): Future[SearchResults] = { -// TEST ON CEREBRO WITH -// {"query": { -// "bool": { -// "must": [ -// { -// "multi_match": { -// "query": "politics", -// "fields": [ -// "id", -// "source.mimeType", -// "metadata.description", -// "metadata.title", -// "metadata.byline", -// "metadata.source", -// "metadata.credit", -// "metadata.keywords", -// "metadata.subLocation", -// "metadata.city", -// "metadata.state", -// "metadata.country", -// "metadata.suppliersReference", -// "metadata.peopleInImage", -// "metadata.specialInstructions", -// "metadata.englishAnalysedCatchAll", -// "metadata.imageType", -// "userMetadata.labels", -// "identifiers.YOUR_PERSISTENCE_IDENTIFIER", -// "identifiers.derivative-of-media-ids", -// "usageRights.restrictions" -// ], -// "type": "best_fields", -// "fuzziness": "AUTO", -// "max_expansions": 50, -// "operator": "and", -// "prefix_length": 1 -// } -// } -// ] -// } -// } -// } +// BM25 scores are unbounded [0,inf] and typically much larger in magnitude +// than cosine similarity (knn). So we get the max BM25 score for the query and use that to calculate +// the scaling factor for the lexical part of the query, so that BM25 and knn scores are both between 0-1 scale +// and can be effectively combined in a hybrid query. def maxBM25Score(query: String): Future[Double] = { - val maxScore = ElasticDsl.search(imagesCurrentAlias) .query(BoolQuery().must( MultiMatchQuery( @@ -260,11 +221,8 @@ class ElasticSearch( maxExpansions = Some(50), operator = Some(And), prefixLength = Some(1), -// boost = Some(1.0) )) ) - .size(0) // we don't actually need the hits, just the max score - val maxScoreFuture = executeAndLog(withSearchQueryTimeout(maxScore), "max BM25 score").map { r => logger.info(logMarker, s"Max BM25 score for query '$query' is ${r.result.hits.maxScore} with total hits ${r.result.totalHits}") if (r.result.hits.hits.isEmpty) 1.0 else r.result.hits.maxScore @@ -282,8 +240,17 @@ class ElasticSearch( val lexicalWeight = 1.0 - vecWeight maxBM25Score(query).flatMap { maxScore => - val scalingFactor = 1.0 / maxScore - val multiMatchBoost = lexicalWeight * scalingFactor +// TODO check if maxScore is 0 and check if vecWeight is 0 + +// KNN results are in [0,1], but BM25 scores are unbounded and typically much +// larger than cosine similarity, so we need to apply a scaling factor to the +// BM25 score to bring it to the same range as the cosine similarity + val scalingFactor = if (maxScore > 0.0) 1.0 / maxScore else 1.0 + +// We want to apply only one boost if we can help it, so we scale the +// multi_match boost to be in line with the max_score and the desired +// lexical_weight/vec_weight balance + val multiMatchBoost = if (vecWeight > 0.0) (lexicalWeight/vecWeight) * scalingFactor else 1.0 logger.info(logMarker, s"Scaling factor for BM25 score is $scalingFactor, multi-match boost is $multiMatchBoost") From 279a4395a8d6d0468deb75d8f1c0145785478507 Mon Sep 17 00:00:00 2001 From: Ellen Muller Date: Thu, 14 May 2026 12:29:06 +0100 Subject: [PATCH 4/7] oops remove scratch that had example cerebro request --- media-api/app/lib/elasticsearch/scratch.txt | 12 ------------ 1 file changed, 12 deletions(-) delete mode 100644 media-api/app/lib/elasticsearch/scratch.txt diff --git a/media-api/app/lib/elasticsearch/scratch.txt b/media-api/app/lib/elasticsearch/scratch.txt deleted file mode 100644 index 5970bc4a59..0000000000 --- a/media-api/app/lib/elasticsearch/scratch.txt +++ /dev/null @@ -1,12 +0,0 @@ -[0.02045091,-0.024293203,0.002695802,0.00023627,-0.009853621,-0.022062195,-0.01512128,0.030614395,-0.017724123,0.01134096,-0.034952465,0.018715683,-0.014005776,-0.025904488,0.0013324078,-0.0070958463,-0.025408708,-0.010659263,-0.042884942,-0.006600067,-0.017972013,0.027639717,0.023673479,-0.029251,-0.0045239893,0.004740893,0.030738339,-0.0027577744,-0.013138161,-0.0028972125,-0.010473345,-0.007467681,0.022310086,-0.045611728,-0.05775833,-0.003392992,0.013200134,-0.018839628,0.0014718459,0.019211462,-0.011774767,-0.0231777,-0.011960684,0.01134096,-0.09717281,0.030366505,-0.009109952,0.025904488,-0.003501444,0.014563528,-0.0069719017,0.03024256,-0.021442471,-0.014377611,-0.017600179,0.00283524,0.014563528,0.01109307,0.007870502,-0.024293203,0.0015338183,-0.038175035,-0.004462017,0.027267883,-0.038670816,0.0413976,0.050817415,-0.00062747113,0.009047979,-0.01636073,0.009915593,0.015988894,-0.0031605954,-0.041149713,0.0076535987,-0.025284763,-0.0038422924,-0.06246824,-0.018095957,0.041645493,-0.014377611,0.028507331,-0.027267883,-0.029374946,-0.0139438035,0.004554976,-0.010287428,-0.047346957,-0.00567048,-0.030738339,-0.028383385,-0.01158885,0.018343847,-0.003392992,-0.01134096,-0.053296313,-0.0047718794,0.027019992,0.04709907,-0.012952244,-0.004833852,0.019335408,-0.007467681,0.017228343,-0.016484674,0.0023859397,0.011217015,-0.0027732674,-0.008614171,0.00189016,0.008800089,-0.028755222,-0.005143714,0.0030056643,0.026400268,0.005825411,0.01134096,0.008738116,0.0231777,0.009977566,-0.016980454,0.030986229,-0.019831186,0.049330078,-0.012828299,-0.0827952,0.016608618,-0.035448246,0.005701466,0.04412439,-0.0020605843,-0.0068789427,-0.01109307,0.0012317026,-0.000042363987,-0.05726255,0.025284763,0.030986229,-0.006104287,0.0014486063,0.030614395,-0.02094669,-0.00066233065,0.008242337,-0.014315638,0.0023549534,0.07734163,-0.001138744,0.014253666,0.01109307,-0.0035324302,0.028755222,-0.0021535428,0.054039985,0.004585962,-0.005825411,-0.02751577,-0.04982586,-0.002990171,0.016980454,0.018467793,-0.049330078,0.08180364,-0.0061662598,-0.062964015,-0.010721236,0.017972013,0.021442471,0.018591737,0.035200357,0.027019992,0.033217236,0.00032535542,-0.0074986676,0.0089860065,-0.008428254,0.010845181,0.013757885,0.02491293,-0.034208797,0.04660329,-0.015431142,0.035200357,-0.024045315,-0.004988783,0.04982586,-0.035448246,0.018219903,-0.019831186,0.021690361,0.026152378,0.02193825,0.019087518,0.040158153,-0.032969348,-0.0027887607,-0.0089860065,-0.028631276,-0.012456465,-0.02491293,0.04585962,-0.015183252,0.011836739,0.035200357,-0.0028817193,-0.027763661,0.016980454,-0.06445136,0.04610751,-0.049082186,0.039910264,0.0062282323,0.0123325195,0.02045091,-0.050321635,-0.035696138,-0.011155043,-0.027763661,0.021194581,0.0033620058,0.00050352624,-0.034456685,0.0030676366,0.04090182,0.03024256,-0.0038887719,-0.0027732674,-0.025408708,0.022310086,0.020326966,0.03222568,0.0005306392,0.0054225903,0.036191914,-0.018219903,-0.022310086,0.010783208,-0.026772102,-0.014749445,0.014005776,0.015431142,0.009171924,-0.039166592,0.035448246,0.05874989,0.014687473,-0.016484674,-0.041149713,-0.042884942,0.0011232508,-0.0070648603,0.017972013,-0.054039985,-0.0438765,-0.03222568,0.007932475] - -{ - "knn": { - "field": "embedding.cohereEmbedV4.image", - "query_vector": [0.02045091,-0.024293203,0.002695802,0.00023627,-0.009853621,-0.022062195,-0.01512128,0.030614395,-0.017724123,0.01134096,-0.034952465,0.018715683,-0.014005776,-0.025904488,0.0013324078,-0.0070958463,-0.025408708,-0.010659263,-0.042884942,-0.006600067,-0.017972013,0.027639717,0.023673479,-0.029251,-0.0045239893,0.004740893,0.030738339,-0.0027577744,-0.013138161,-0.0028972125,-0.010473345,-0.007467681,0.022310086,-0.045611728,-0.05775833,-0.003392992,0.013200134,-0.018839628,0.0014718459,0.019211462,-0.011774767,-0.0231777,-0.011960684,0.01134096,-0.09717281,0.030366505,-0.009109952,0.025904488,-0.003501444,0.014563528,-0.0069719017,0.03024256,-0.021442471,-0.014377611,-0.017600179,0.00283524,0.014563528,0.01109307,0.007870502,-0.024293203,0.0015338183,-0.038175035,-0.004462017,0.027267883,-0.038670816,0.0413976,0.050817415,-0.00062747113,0.009047979,-0.01636073,0.009915593,0.015988894,-0.0031605954,-0.041149713,0.0076535987,-0.025284763,-0.0038422924,-0.06246824,-0.018095957,0.041645493,-0.014377611,0.028507331,-0.027267883,-0.029374946,-0.0139438035,0.004554976,-0.010287428,-0.047346957,-0.00567048,-0.030738339,-0.028383385,-0.01158885,0.018343847,-0.003392992,-0.01134096,-0.053296313,-0.0047718794,0.027019992,0.04709907,-0.012952244,-0.004833852,0.019335408,-0.007467681,0.017228343,-0.016484674,0.0023859397,0.011217015,-0.0027732674,-0.008614171,0.00189016,0.008800089,-0.028755222,-0.005143714,0.0030056643,0.026400268,0.005825411,0.01134096,0.008738116,0.0231777,0.009977566,-0.016980454,0.030986229,-0.019831186,0.049330078,-0.012828299,-0.0827952,0.016608618,-0.035448246,0.005701466,0.04412439,-0.0020605843,-0.0068789427,-0.01109307,0.0012317026,-0.000042363987,-0.05726255,0.025284763,0.030986229,-0.006104287,0.0014486063,0.030614395,-0.02094669,-0.00066233065,0.008242337,-0.014315638,0.0023549534,0.07734163,-0.001138744,0.014253666,0.01109307,-0.0035324302,0.028755222,-0.0021535428,0.054039985,0.004585962,-0.005825411,-0.02751577,-0.04982586,-0.002990171,0.016980454,0.018467793,-0.049330078,0.08180364,-0.0061662598,-0.062964015,-0.010721236,0.017972013,0.021442471,0.018591737,0.035200357,0.027019992,0.033217236,0.00032535542,-0.0074986676,0.0089860065,-0.008428254,0.010845181,0.013757885,0.02491293,-0.034208797,0.04660329,-0.015431142,0.035200357,-0.024045315,-0.004988783,0.04982586,-0.035448246,0.018219903,-0.019831186,0.021690361,0.026152378,0.02193825,0.019087518,0.040158153,-0.032969348,-0.0027887607,-0.0089860065,-0.028631276,-0.012456465,-0.02491293,0.04585962,-0.015183252,0.011836739,0.035200357,-0.0028817193,-0.027763661,0.016980454,-0.06445136,0.04610751,-0.049082186,0.039910264,0.0062282323,0.0123325195,0.02045091,-0.050321635,-0.035696138,-0.011155043,-0.027763661,0.021194581,0.0033620058,0.00050352624,-0.034456685,0.0030676366,0.04090182,0.03024256,-0.0038887719,-0.0027732674,-0.025408708,0.022310086,0.020326966,0.03222568,0.0005306392,0.0054225903,0.036191914,-0.018219903,-0.022310086,0.010783208,-0.026772102,-0.014749445,0.014005776,0.015431142,0.009171924,-0.039166592,0.035448246,0.05874989,0.014687473,-0.016484674,-0.041149713,-0.042884942,0.0011232508,-0.0070648603,0.017972013,-0.054039985,-0.0438765,-0.03222568,0.007932475], - "k": 200, - "num_candidates": 400 - }, - "size": 10, - "timeout": "10s" -} From cf10157fda1f92d62461b12954dc5e5c1cb0a053 Mon Sep 17 00:00:00 2001 From: Ellen Muller Date: Thu, 14 May 2026 14:19:04 +0100 Subject: [PATCH 5/7] not working, intermediate commit --- kahuna/public/js/search/index.js | 1 + kahuna/public/js/search/query.html | 3 ++- kahuna/public/js/search/query.js | 2 ++ kahuna/public/js/search/results.js | 1 + kahuna/public/js/services/api/media-api.js | 5 +++-- media-api/app/controllers/MediaApi.scala | 12 +++++++----- media-api/app/lib/elasticsearch/ElasticSearch.scala | 3 +-- .../app/lib/elasticsearch/ElasticSearchModel.scala | 2 ++ 8 files changed, 19 insertions(+), 10 deletions(-) diff --git a/kahuna/public/js/search/index.js b/kahuna/public/js/search/index.js index 5d57125684..8897e6006e 100644 --- a/kahuna/public/js/search/index.js +++ b/kahuna/public/js/search/index.js @@ -179,6 +179,7 @@ search.config(['$stateProvider', '$urlMatcherFactoryProvider', 'until', 'orderBy', 'useAISearch', + 'vecWeight', 'dateField', 'takenSince', 'takenUntil', diff --git a/kahuna/public/js/search/query.html b/kahuna/public/js/search/query.html index 8cfde8b115..c1a33f4264 100644 --- a/kahuna/public/js/search/query.html +++ b/kahuna/public/js/search/query.html @@ -3,7 +3,8 @@ ng-if="searchQuery.shouldDisplayAISearchOption"> diff --git a/kahuna/public/js/search/query.js b/kahuna/public/js/search/query.js index 56d1e255b1..9c79963dff 100644 --- a/kahuna/public/js/search/query.js +++ b/kahuna/public/js/search/query.js @@ -82,8 +82,10 @@ query.controller('SearchQueryCtrl', [ ctrl.shouldDisplayAISearchOption = getFeatureSwitchActive("enable-ai-search"); if (!ctrl.shouldDisplayAISearchOption) { ctrl.useAISearch = false; + ctrl.vecWeight = false; } else { ctrl.useAISearch = ($stateParams.useAISearch === 'true' || $stateParams.useAISearch === true) ? true : false; + ctrl.vecWeight = ($stateParams.vecWeight === 8 || $stateParams.vecWeight === true) ? true : false; } //--react - angular interop events-- diff --git a/kahuna/public/js/search/results.js b/kahuna/public/js/search/results.js index ce3a30afcb..1268fd9a9e 100644 --- a/kahuna/public/js/search/results.js +++ b/kahuna/public/js/search/results.js @@ -578,6 +578,7 @@ results.controller('SearchResultsCtrl', [ length: length, orderBy: orderBy, useAISearch: $stateParams.useAISearch, + vecWeight: $stateParams.vecWeight, hasRightsAcquired: $stateParams.hasRightsAcquired, hasCrops: $stateParams.hasCrops, syndicationStatus: $stateParams.syndicationStatus, diff --git a/kahuna/public/js/services/api/media-api.js b/kahuna/public/js/services/api/media-api.js index 845f4df31b..7dbf698a2a 100644 --- a/kahuna/public/js/services/api/media-api.js +++ b/kahuna/public/js/services/api/media-api.js @@ -42,7 +42,7 @@ mediaApi.factory('mediaApi', payType, uploadedBy, offset, length, orderBy, takenSince, takenUntil, modifiedSince, modifiedUntil, hasRightsAcquired, hasCrops, - syndicationStatus, countAll, persisted, useAISearch} = {}) { + syndicationStatus, countAll, persisted, useAISearch, vecWeight} = {}) { return root.follow('search', { q: query, since: since, @@ -65,7 +65,8 @@ mediaApi.factory('mediaApi', syndicationStatus: syndicationStatus, countAll, persisted, - useAISearch: maybeStringToBoolean(useAISearch) + useAISearch: maybeStringToBoolean(useAISearch), + vecWeight: vecWeight, }).get(); } diff --git a/media-api/app/controllers/MediaApi.scala b/media-api/app/controllers/MediaApi.scala index 5172fc1d45..26fc9eae55 100644 --- a/media-api/app/controllers/MediaApi.scala +++ b/media-api/app/controllers/MediaApi.scala @@ -88,7 +88,8 @@ class MediaApi( "syndicationStatus", "countAll", "persisted", - "useAISearch" + "useAISearch", + "vecWeight" ).mkString(",") private val searchLinkHref = s"${config.rootUri}/images{?$searchParamList}" @@ -618,7 +619,7 @@ class MediaApi( } yield searchResults } - def semanticSearchByText(query: String, k: Int): Future[SearchResults] = { + def semanticSearchByText(query: String, k: Int, vecWeight: Int): Future[SearchResults] = { // Normalise key so that "Dogs" and "dogs " share a cache entry. val cacheKey = query.trim.toLowerCase @@ -634,6 +635,7 @@ class MediaApi( // load fires and both callers receive the same Future. val embeddingFuture = embeddingCache.get(cacheKey) + logger.info(markers, s"vecWeight for query '$query' is $vecWeight") for { embedding <- embeddingFuture searchResults <- elasticSearch.hybridSearch( @@ -646,11 +648,11 @@ class MediaApi( } yield searchResults } - def performAiSearchAndRespond(query: String): Future[Result] = { + def performAiSearchAndRespond(query: String, vecWeight: Option[Int]): Future[Result] = { val k = config.aiSearchResultLimit val searchResultsFuture = parseAiSearchMode(query) match { case SimilarSearch(imageId) => semanticSearchByImage(imageId, k) - case TextSearch(textQuery) => semanticSearchByText(textQuery, k) + case TextSearch(textQuery) => semanticSearchByText(textQuery, k, vecWeight.getOrElse(0)) } searchResultsFuture.map(aiSearchResponseFromResults) @@ -662,7 +664,7 @@ class MediaApi( case _ if _searchParams.length == 0 => emptyAiSearchResponse case Some(q) if !q.isBlank => - performAiSearchAndRespond(q) + performAiSearchAndRespond(q, _searchParams.vecWeight) // Empty queries do not make sense for AI search as we can // only rank results once we have a meaningful vector to compare with. // So return 0 results if the query was empty. diff --git a/media-api/app/lib/elasticsearch/ElasticSearch.scala b/media-api/app/lib/elasticsearch/ElasticSearch.scala index a9ea65ae5d..63700ffd5d 100644 --- a/media-api/app/lib/elasticsearch/ElasticSearch.scala +++ b/media-api/app/lib/elasticsearch/ElasticSearch.scala @@ -240,8 +240,6 @@ class ElasticSearch( val lexicalWeight = 1.0 - vecWeight maxBM25Score(query).flatMap { maxScore => -// TODO check if maxScore is 0 and check if vecWeight is 0 - // KNN results are in [0,1], but BM25 scores are unbounded and typically much // larger than cosine similarity, so we need to apply a scaling factor to the // BM25 score to bring it to the same range as the cosine similarity @@ -254,6 +252,7 @@ class ElasticSearch( logger.info(logMarker, s"Scaling factor for BM25 score is $scalingFactor, multi-match boost is $multiMatchBoost") +// TODO make case class for multimatchQuery to avoid repetition val multiMatchQuery = MultiMatchQuery( text = query, fields = matchFields.map(field => FieldWithOptionalBoost(field, None)), diff --git a/media-api/app/lib/elasticsearch/ElasticSearchModel.scala b/media-api/app/lib/elasticsearch/ElasticSearchModel.scala index a0cd6a0db7..92e10299da 100644 --- a/media-api/app/lib/elasticsearch/ElasticSearchModel.scala +++ b/media-api/app/lib/elasticsearch/ElasticSearchModel.scala @@ -86,6 +86,7 @@ case class SearchParams( printUsageFilters: Option[PrintUsageFilters] = None, shouldFlagGraphicImages: Boolean = false, useAISearch: Option[Boolean] = None, + vecWeight: Option[Int] = None ) case class InvalidUriParams(message: String) extends Throwable @@ -175,6 +176,7 @@ object SearchParams { printUsageFilters, shouldFlagGraphicImages = false, request.getQueryString("useAISearch") flatMap parseBooleanFromQuery, + request.getQueryString("vecWeight") flatMap parseIntFromQuery, ) } From 6587acdf5db1935f01d785b18da499b8cab6230a Mon Sep 17 00:00:00 2001 From: Ellen Muller Date: Thu, 14 May 2026 14:53:22 +0100 Subject: [PATCH 6/7] add vecweight parameter to control hybrid search --- kahuna/public/js/search/query.html | 3 +-- kahuna/public/js/search/query.js | 7 ++++--- media-api/app/controllers/MediaApi.scala | 12 +++++++----- .../app/lib/elasticsearch/ElasticSearchModel.scala | 5 +++-- 4 files changed, 15 insertions(+), 12 deletions(-) diff --git a/kahuna/public/js/search/query.html b/kahuna/public/js/search/query.html index c1a33f4264..8cfde8b115 100644 --- a/kahuna/public/js/search/query.html +++ b/kahuna/public/js/search/query.html @@ -3,8 +3,7 @@ ng-if="searchQuery.shouldDisplayAISearchOption"> diff --git a/kahuna/public/js/search/query.js b/kahuna/public/js/search/query.js index 9c79963dff..6066e77478 100644 --- a/kahuna/public/js/search/query.js +++ b/kahuna/public/js/search/query.js @@ -85,7 +85,7 @@ query.controller('SearchQueryCtrl', [ ctrl.vecWeight = false; } else { ctrl.useAISearch = ($stateParams.useAISearch === 'true' || $stateParams.useAISearch === true) ? true : false; - ctrl.vecWeight = ($stateParams.vecWeight === 8 || $stateParams.vecWeight === true) ? true : false; + ctrl.vecWeight = $stateParams.vecWeight; } //--react - angular interop events-- @@ -457,11 +457,12 @@ query.controller('SearchQueryCtrl', [ if (ctrl.useAISearch) { $state.go('search.results', { ...ctrl.filter, - useAISearch: true + useAISearch: true, + vecWeight: ctrl.vecWeight }); } else { $state.go('search.results', {...ctrl.filter, useAISearch: null}); - } + } }); $scope.$watchCollection(() => ctrl.dateFilter, onValChange(({field, since, until}) => { diff --git a/media-api/app/controllers/MediaApi.scala b/media-api/app/controllers/MediaApi.scala index 26fc9eae55..9fb8455f05 100644 --- a/media-api/app/controllers/MediaApi.scala +++ b/media-api/app/controllers/MediaApi.scala @@ -619,7 +619,7 @@ class MediaApi( } yield searchResults } - def semanticSearchByText(query: String, k: Int, vecWeight: Int): Future[SearchResults] = { + def semanticSearchByText(query: String, k: Int, vecWeight: Option[Double]): Future[SearchResults] = { // Normalise key so that "Dogs" and "dogs " share a cache entry. val cacheKey = query.trim.toLowerCase @@ -631,11 +631,13 @@ class MediaApi( logger.info(markers, s"AI search embedding cache miss query=$query") } + val weight = vecWeight.getOrElse(0.8) + // cache.get(key) is atomic: if two requests race on the same key, only one // load fires and both callers receive the same Future. val embeddingFuture = embeddingCache.get(cacheKey) - logger.info(markers, s"vecWeight for query '$query' is $vecWeight") + logger.info(markers, s"vecWeight for query '$query' is $weight") for { embedding <- embeddingFuture searchResults <- elasticSearch.hybridSearch( @@ -643,16 +645,16 @@ class MediaApi( queryEmbedding = embedding, k = k, numCandidates = Math.max(k * 2, 100), - vecWeight = 0.0, // TODO hardcode the actual constant here + vecWeight = weight, ) } yield searchResults } - def performAiSearchAndRespond(query: String, vecWeight: Option[Int]): Future[Result] = { + def performAiSearchAndRespond(query: String, vecWeight: Option[Double]): Future[Result] = { val k = config.aiSearchResultLimit val searchResultsFuture = parseAiSearchMode(query) match { case SimilarSearch(imageId) => semanticSearchByImage(imageId, k) - case TextSearch(textQuery) => semanticSearchByText(textQuery, k, vecWeight.getOrElse(0)) + case TextSearch(textQuery) => semanticSearchByText(textQuery, k, vecWeight) } searchResultsFuture.map(aiSearchResponseFromResults) diff --git a/media-api/app/lib/elasticsearch/ElasticSearchModel.scala b/media-api/app/lib/elasticsearch/ElasticSearchModel.scala index 92e10299da..7ea48b73fc 100644 --- a/media-api/app/lib/elasticsearch/ElasticSearchModel.scala +++ b/media-api/app/lib/elasticsearch/ElasticSearchModel.scala @@ -86,7 +86,7 @@ case class SearchParams( printUsageFilters: Option[PrintUsageFilters] = None, shouldFlagGraphicImages: Boolean = false, useAISearch: Option[Boolean] = None, - vecWeight: Option[Int] = None + vecWeight: Option[Double] = None ) case class InvalidUriParams(message: String) extends Throwable @@ -116,6 +116,7 @@ object SearchParams { // TODO: return descriptive 400 error if invalid def parseIntFromQuery(s: String): Option[Int] = Try(s.toInt).toOption + def parseDoubleFromQuery(s: String): Option[Double] = Try(s.toDouble).toOption def parsePayTypeFromQuery(s: String): Option[PayType.Value] = PayType.create(s) def parseBooleanFromQuery(s: String): Option[Boolean] = Try(s.toBoolean).toOption def parseSyndicationStatus(s: String): Option[SyndicationStatus] = Some(SyndicationStatus(s)) @@ -176,7 +177,7 @@ object SearchParams { printUsageFilters, shouldFlagGraphicImages = false, request.getQueryString("useAISearch") flatMap parseBooleanFromQuery, - request.getQueryString("vecWeight") flatMap parseIntFromQuery, + request.getQueryString("vecWeight") flatMap parseDoubleFromQuery, ) } From 1b16745a4044c698400833247247e153f32a70ef Mon Sep 17 00:00:00 2001 From: Ellen Muller Date: Thu, 14 May 2026 14:57:52 +0100 Subject: [PATCH 7/7] remove trailing comma that broke kahuna linting --- kahuna/public/js/services/api/media-api.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kahuna/public/js/services/api/media-api.js b/kahuna/public/js/services/api/media-api.js index 7dbf698a2a..4687cb6277 100644 --- a/kahuna/public/js/services/api/media-api.js +++ b/kahuna/public/js/services/api/media-api.js @@ -66,7 +66,7 @@ mediaApi.factory('mediaApi', countAll, persisted, useAISearch: maybeStringToBoolean(useAISearch), - vecWeight: vecWeight, + vecWeight: vecWeight }).get(); }