From 84fbfc1c8b332168282fd5368b706d34c934ecd7 Mon Sep 17 00:00:00 2001 From: Ellen Muller Date: Mon, 1 Jun 2026 15:59:01 +0100 Subject: [PATCH 01/20] split out query from chips and only embed query --- media-api/app/controllers/MediaApi.scala | 43 +++++++++++++++++------- 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/media-api/app/controllers/MediaApi.scala b/media-api/app/controllers/MediaApi.scala index 5b32930b4a..a964f288f8 100644 --- a/media-api/app/controllers/MediaApi.scala +++ b/media-api/app/controllers/MediaApi.scala @@ -18,6 +18,7 @@ import com.gu.mediaservice.syntax.MessageSubjects import com.gu.mediaservice.{GridClient, JsonDiff} import lib._ import lib.elasticsearch._ +import lib.querysyntax.{AnyField, Match, Parser, Phrase, Words} import org.apache.http.entity.ContentType import org.apache.pekko.stream.scaladsl.StreamConverters import org.http4s.UriTemplate @@ -587,6 +588,15 @@ class MediaApi( .getOrElse(TextSearch(query)) } + def extractSemanticText(params: SearchParams): Option[String] = { + val queries = params.structuredQuery.collect { + case Match(AnyField, Words(value)) => value + case Match(AnyField, Phrase(value)) => value + } + logger.info(logMarker, s"Extracted semantic query from structured query: ${queries.mkString(" ")}") + if (queries.nonEmpty) Some(queries.mkString(" ")) else None + } + def emptyAiSearchResponse = Future.successful(respondCollection(List.empty[EmbeddedEntity[JsValue]], Some(0), Some(0), None, List())) @@ -619,42 +629,49 @@ class MediaApi( } yield 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 + def semanticSearchByText(query: String, k: Int, params: SearchParams): Future[SearchResults] = { + // Separate the chips from the main query text + // So that we can embed just the query text + val semanticQuery = extractSemanticText(params: SearchParams) + .getOrElse { + logger.info(logMarker, s"No semantic query found in structured query, falling back to embedding the full query text: $query") + query + } - val markers = logMarker ++ Map("query" -> query) + // Normalise key so that "Dogs" and "dogs " share a cache entry. + val cacheKey = semanticQuery.trim.toLowerCase + val markers = logMarker ++ Map("query" -> semanticQuery) if (embeddingCache.getIfPresent(cacheKey).isDefined) { - logger.info(markers, s"AI search embedding cache hit query=$query") + logger.info(markers, s"AI search embedding cache hit query=$semanticQuery") } else { - logger.info(markers, s"AI search embedding cache miss query=$query") + logger.info(markers, s"AI search embedding cache miss query=$semanticQuery") } - val weight = vecWeight.getOrElse(1.0) + val weight = params.vecWeight.getOrElse(1.0) // 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 $weight") + logger.info(markers, s"vecWeight for query '$semanticQuery' is $weight") for { embedding <- embeddingFuture searchResults <- elasticSearch.hybridSearch( - query = query, + query = semanticQuery, queryEmbedding = embedding, k = k, numCandidates = Math.max(k * 2, 100), - vecWeight = weight, + vecWeight = weight ) } yield searchResults } - def performAiSearchAndRespond(query: String, vecWeight: Option[Double]): Future[Result] = { + def performAiSearchAndRespond(query: String, params: SearchParams): Future[Result] = { val k = config.aiSearchResultLimit val searchResultsFuture = parseAiSearchMode(query) match { case SimilarSearch(imageId) => semanticSearchByImage(imageId, k) - case TextSearch(textQuery) => semanticSearchByText(textQuery, k, vecWeight) + case TextSearch(textQuery) => semanticSearchByText(textQuery, k, params) } searchResultsFuture.map(aiSearchResponseFromResults) @@ -666,7 +683,7 @@ class MediaApi( case _ if _searchParams.length == 0 => emptyAiSearchResponse case Some(q) if !q.isBlank => - performAiSearchAndRespond(q, _searchParams.vecWeight) + performAiSearchAndRespond(q, _searchParams) // 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. From f1f589b4b4f88da9a2554b3fb6b87accca361f6a Mon Sep 17 00:00:00 2001 From: Ellen Muller Date: Tue, 2 Jun 2026 12:11:55 +0100 Subject: [PATCH 02/20] make similar part of model and split out filteropt --- media-api/app/controllers/MediaApi.scala | 30 +++--- .../app/lib/elasticsearch/ElasticSearch.scala | 85 ++--------------- .../app/lib/elasticsearch/QueryBuilder.scala | 93 ++++++++++++++++++- .../app/lib/querysyntax/QuerySyntax.scala | 6 ++ media-api/app/lib/querysyntax/model.scala | 2 + .../test/lib/querysyntax/ParserTest.scala | 18 ++++ 6 files changed, 139 insertions(+), 95 deletions(-) diff --git a/media-api/app/controllers/MediaApi.scala b/media-api/app/controllers/MediaApi.scala index a964f288f8..81b0c920e4 100644 --- a/media-api/app/controllers/MediaApi.scala +++ b/media-api/app/controllers/MediaApi.scala @@ -18,7 +18,7 @@ import com.gu.mediaservice.syntax.MessageSubjects import com.gu.mediaservice.{GridClient, JsonDiff} import lib._ import lib.elasticsearch._ -import lib.querysyntax.{AnyField, Match, Parser, Phrase, Words} +import lib.querysyntax.{AnyField, Match, Parser, Phrase, SimilarField, SimilarValue, Words} import org.apache.http.entity.ContentType import org.apache.pekko.stream.scaladsl.StreamConverters import org.http4s.UriTemplate @@ -579,16 +579,14 @@ class MediaApi( case class TextSearch(query: String) extends AiSearchMode case class SimilarSearch(imageId: String) extends AiSearchMode - def parseAiSearchMode(query: String): AiSearchMode = { - query - .split("\\s+") - .find(_.startsWith("similar:")) - .flatMap(_.split(":", 2).lift(1).filter(_.nonEmpty)) - .map(SimilarSearch) - .getOrElse(TextSearch(query)) + def parseAiSearchMode(query: String, params: SearchParams): AiSearchMode = { + params.structuredQuery.collectFirst { + case Match(SimilarField, SimilarValue(imageId)) if imageId.nonEmpty => SimilarSearch(imageId) + }.getOrElse(TextSearch(query)) } - def extractSemanticText(params: SearchParams): Option[String] = { + def extractSemanticTextQuery(params: SearchParams): Option[String] = { +// logger.info(logMarker, s"Extracting semantic query from structured query: ${params.structuredQuery}") val queries = params.structuredQuery.collect { case Match(AnyField, Words(value)) => value case Match(AnyField, Phrase(value)) => value @@ -611,7 +609,7 @@ class MediaApi( ) } - def semanticSearchByImage(imageId: String, k: Int): Future[SearchResults] = { + def semanticSearchByImage(imageId: String, k: Int, params: SearchParams): Future[SearchResults] = { for { maybeImage <- elasticSearch.getImageById(imageId) maybeEmbedding = maybeImage @@ -622,7 +620,7 @@ class MediaApi( searchResults <- maybeEmbedding match { // If we have an embedding, perform the KNN search. If not, return an empty result set. case Some(embedding) => - elasticSearch.knnSearch(embedding, k = k, numCandidates = Math.max(k * 2, 100)) + elasticSearch.knnSearch(embedding, k = k, numCandidates = Math.max(k * 2, 100), params) case None => Future.successful(SearchResults(Nil, total = 0, extraCounts = None)) } @@ -632,7 +630,8 @@ class MediaApi( def semanticSearchByText(query: String, k: Int, params: SearchParams): Future[SearchResults] = { // Separate the chips from the main query text // So that we can embed just the query text - val semanticQuery = extractSemanticText(params: SearchParams) + val semanticQuery = extractSemanticTextQuery(params: SearchParams) +// TODO remove this getorelse, handle the empty case properly .getOrElse { logger.info(logMarker, s"No semantic query found in structured query, falling back to embedding the full query text: $query") query @@ -662,15 +661,16 @@ class MediaApi( queryEmbedding = embedding, k = k, numCandidates = Math.max(k * 2, 100), - vecWeight = weight + vecWeight = weight, + params = params ) } yield searchResults } def performAiSearchAndRespond(query: String, params: SearchParams): Future[Result] = { val k = config.aiSearchResultLimit - val searchResultsFuture = parseAiSearchMode(query) match { - case SimilarSearch(imageId) => semanticSearchByImage(imageId, k) + val searchResultsFuture = parseAiSearchMode(query, params) match { + case SimilarSearch(imageId) => semanticSearchByImage(imageId, k, params) case TextSearch(textQuery) => semanticSearchByText(textQuery, k, params) } diff --git a/media-api/app/lib/elasticsearch/ElasticSearch.scala b/media-api/app/lib/elasticsearch/ElasticSearch.scala index 175fe3d63c..aead53a7b1 100644 --- a/media-api/app/lib/elasticsearch/ElasticSearch.scala +++ b/media-api/app/lib/elasticsearch/ElasticSearch.scala @@ -178,8 +178,11 @@ class ElasticSearch( } } - def knnSearch(queryEmbedding: List[Float], k: Int, numCandidates: Int) + def knnSearch(queryEmbedding: List[Float], k: Int, numCandidates: Int, params: SearchParams) (implicit ex: ExecutionContext, logMarker: LogMarker): Future[SearchResults] = { +// TODO add relevant search parameters to the search + logger.info(logMarker, s"$params still need to do something with the params here") + val knn = Knn("embedding.cohereEmbedV4.image") .queryVector(queryEmbedding.map(_.toDouble)) .k(k) @@ -262,6 +265,7 @@ class ElasticSearch( k: Int, numCandidates: Int, vecWeight: Double, + params: SearchParams )( implicit ex: ExecutionContext, logMarker: LogMarker @@ -281,84 +285,7 @@ class ElasticSearch( def search(params: SearchParams)(implicit ex: ExecutionContext, request: AuthenticatedRequest[AnyContent, Principal], logMarker: LogMarker = MarkerMap()): Future[SearchResults] = { val query: Query = queryBuilder.makeQuery(params.structuredQuery) - val uploadTimeFilter = filters.date("uploadTime", params.since, params.until) - val lastModTimeFilter = filters.date("lastModified", params.modifiedSince, params.modifiedUntil) - val takenTimeFilter = filters.date("metadata.dateTaken", params.takenSince, params.takenUntil) - // we only inject filters if there are actual date parameters - val dateFilterList = List(uploadTimeFilter, lastModTimeFilter, takenTimeFilter).flatten.toNel - val dateFilter = dateFilterList.map(dateFilters => filters.and(dateFilters.list.toList: _*)) - - val idsFilter = params.ids.map(filters.ids) - val labelFilter = params.labels.toNel.map(filters.terms("labels", _)) - val metadataFilter = params.hasMetadata.map(metadataField).toNel.map(filters.exists) - val archivedFilter = params.archived.map(filters.existsOrMissing(editsField("archived"), _)) - val hasExports = params.hasExports.map(filters.existsOrMissing("exports", _)) - val hasIdentifier = params.hasIdentifier.map(idName => filters.exists(NonEmptyList(identifierField(idName)))) - val missingIdentifier = params.missingIdentifier.map(idName => filters.missing(NonEmptyList(identifierField(idName)))) - val uploadedByFilter = params.uploadedBy.map(uploadedBy => filters.terms("uploadedBy", NonEmptyList(uploadedBy))) - val simpleCostFilter = params.free.flatMap(free => if (free) searchFilters.freeFilter else searchFilters.nonFreeFilter) - val costFilter = params.payType match { - case Some(PayType.Free) => searchFilters.freeFilter - case Some(PayType.MaybeFree) => searchFilters.maybeFreeFilter - case Some(PayType.Pay) => searchFilters.nonFreeFilter - case _ => None - } - - val printUsageFilter = params.printUsageFilters.map(searchFilters.printUsageFilters) - - val hasRightsCategory = params.hasRightsCategory.filter(_ == true).map(_ => searchFilters.hasRightsCategoryFilter) - - val validityFilter = params.valid.map(valid => if (valid) searchFilters.validFilter else searchFilters.invalidFilter) - - val persistFilter = params.persisted map { - case true => searchFilters.persistedFilter - case false => searchFilters.nonPersistedFilter - } - - val usageFilter: Iterable[Query] = - params.usageStatus.toNel.map(status => filters.terms("usagesStatus", status.map(_.toString))).toOption ++ - params.usagePlatform.toNel.map(filters.terms("usagesPlatform", _)).toOption - - val syndicationStatusFilter = params.syndicationStatus.map(status => syndicationFilter.statusFilter(status)) - - // Port of special case code in elastic1 sorts. Using the dateAddedToCollection sort implies an additional filter for reasons unknown - val dateAddedToCollectionFilter = { - params.orderBy match { - case Some("dateAddedToCollection") => { - val pathHierarchyOpt = params.structuredQuery.flatMap { - case Match(HierarchyField, Phrase(value)) => Some(value) - case _ => None - }.headOption - - pathHierarchyOpt.map { pathHierarchy => - termQuery("collections.pathHierarchy", pathHierarchy) - } - } - case _ => None - } - } - - val filterOpt = ( - metadataFilter.toOption.toList - ++ persistFilter - ++ labelFilter.toOption - ++ archivedFilter - ++ uploadedByFilter - ++ idsFilter - ++ validityFilter - ++ simpleCostFilter - ++ costFilter - ++ hasExports - ++ hasIdentifier - ++ missingIdentifier - ++ dateFilter.toOption - ++ usageFilter - ++ hasRightsCategory - ++ searchFilters.tierFilter(params.tier) - ++ syndicationStatusFilter - ++ dateAddedToCollectionFilter - ++ printUsageFilter - ).toNel.map(filter => filter.list.toList.reduceLeft(filters.and(_, _))).toOption + val filterOpt: Option[Query] = queryBuilder.buildFilterOpt(params, searchFilters, syndicationFilter) val withFilter = filterOpt.map { f => boolQuery() must (query) filter f diff --git a/media-api/app/lib/elasticsearch/QueryBuilder.scala b/media-api/app/lib/elasticsearch/QueryBuilder.scala index 7b53c02c4c..0587504c0f 100644 --- a/media-api/app/lib/elasticsearch/QueryBuilder.scala +++ b/media-api/app/lib/elasticsearch/QueryBuilder.scala @@ -1,7 +1,7 @@ package lib.elasticsearch import com.gu.mediaservice.lib.ImageFields -import com.gu.mediaservice.lib.elasticsearch.IndexSettings +import com.gu.mediaservice.lib.elasticsearch.filters import com.gu.mediaservice.lib.formatting.printDateTime import com.gu.mediaservice.lib.logging.GridLogging import com.gu.mediaservice.model.Agency @@ -12,6 +12,8 @@ import com.sksamuel.elastic4s.requests.searches.queries.Query import com.sksamuel.elastic4s.requests.searches.queries.matches.{MultiMatchQuery, MultiMatchQueryBuilderType} import lib.querysyntax._ import lib.MediaApiConfig +import scalaz.NonEmptyList +import scalaz.syntax.std.list._ class QueryBuilder(matchFields: Seq[String], overQuotaAgencies: () => List[Agency], config: MediaApiConfig) extends ImageFields with GridLogging { @@ -82,6 +84,9 @@ class QueryBuilder(matchFields: Seq[String], overQuotaAgencies: () => List[Agenc matchNoneQuery() } } + case SimilarField => + logger.info(s"Cannot perform SIMILAR query on ${condition.value} outside AI search mode") + matchNoneQuery() } def makeQuery(conditions: List[Condition]) = conditions match { @@ -122,4 +127,90 @@ class QueryBuilder(matchFields: Seq[String], overQuotaAgencies: () => List[Agenc listOfNestedToQueries(negationNested).foldLeft(queryWithNestedAndNormal) { case (q, negNestedQ) => q.withNot(negNestedQ) } } } + + def buildFilterOpt(params: SearchParams, searchFilters: SearchFilters, syndicationFilter: SyndicationFilter): Option[Query] = { + + val uploadTimeFilter = filters.date("uploadTime", params.since, params.until) + val lastModTimeFilter = filters.date("lastModified", params.modifiedSince, params.modifiedUntil) + val takenTimeFilter = filters.date("metadata.dateTaken", params.takenSince, params.takenUntil) + // we only inject filters if there are actual date parameters + val dateFilterList = List(uploadTimeFilter, lastModTimeFilter, takenTimeFilter).flatten.toNel + val dateFilter = dateFilterList.map(dateFilters => filters.and(dateFilters.list.toList: _*)) + + val idsFilter = params.ids.map(filters.ids) + val labelFilter = params.labels.toNel.map(filters.terms("labels", _)) + val metadataFilter = params.hasMetadata.map(metadataField).toNel.map(filters.exists) + val archivedFilter = params.archived.map(filters.existsOrMissing(editsField("archived"), _)) + val hasExports = params.hasExports.map(filters.existsOrMissing("exports", _)) + val hasIdentifier = params.hasIdentifier.map(idName => filters.exists(NonEmptyList(identifierField(idName)))) + val missingIdentifier = params.missingIdentifier.map(idName => filters.missing(NonEmptyList(identifierField(idName)))) + val uploadedByFilter = params.uploadedBy.map(uploadedBy => filters.terms("uploadedBy", NonEmptyList(uploadedBy))) + val simpleCostFilter = params.free.flatMap(free => if (free) searchFilters.freeFilter else searchFilters.nonFreeFilter) + val costFilter = params.payType match { + case Some(PayType.Free) => searchFilters.freeFilter + case Some(PayType.MaybeFree) => searchFilters.maybeFreeFilter + case Some(PayType.Pay) => searchFilters.nonFreeFilter + case _ => None + } + + val printUsageFilter = params.printUsageFilters.map(searchFilters.printUsageFilters) + + val hasRightsCategory = params.hasRightsCategory.filter(_ == true).map(_ => searchFilters.hasRightsCategoryFilter) + + val validityFilter = params.valid.map(valid => if (valid) searchFilters.validFilter else searchFilters.invalidFilter) + + val persistFilter = params.persisted map { + case true => searchFilters.persistedFilter + case false => searchFilters.nonPersistedFilter + } + + val usageFilter: Iterable[Query] = + params.usageStatus.toNel.map(status => filters.terms("usagesStatus", status.map(_.toString))).toOption ++ + params.usagePlatform.toNel.map(filters.terms("usagesPlatform", _)).toOption + + val syndicationStatusFilter = params.syndicationStatus.map(status => syndicationFilter.statusFilter(status)) + + // Port of special case code in elastic1 sorts. Using the dateAddedToCollection sort implies an additional filter for reasons unknown + val dateAddedToCollectionFilter = { + params.orderBy match { + case Some("dateAddedToCollection") => { + val pathHierarchyOpt = params.structuredQuery.flatMap { + case Match(HierarchyField, Phrase(value)) => Some(value) + case _ => None + }.headOption + + pathHierarchyOpt.map { pathHierarchy => + termQuery("collections.pathHierarchy", pathHierarchy) + } + } + case _ => None + } + } + + val filterOpt = ( + metadataFilter.toOption.toList + ++ persistFilter + ++ labelFilter.toOption + ++ archivedFilter + ++ uploadedByFilter + ++ idsFilter + ++ validityFilter + ++ simpleCostFilter + ++ costFilter + ++ hasExports + ++ hasIdentifier + ++ missingIdentifier + ++ dateFilter.toOption + ++ usageFilter + ++ hasRightsCategory + ++ searchFilters.tierFilter(params.tier) + ++ syndicationStatusFilter + ++ dateAddedToCollectionFilter + ++ printUsageFilter + ).toNel.map(filter => filter.list.toList.reduceLeft(filters.and(_, _))).toOption + // logger.info(s"Built filters: ${filterOpt.map(_.toString).getOrElse("None")}") +// eg Built filters: BoolQuery(None,None,None,None,List(),ArraySeq(BoolQuery(None,None,None,None,List(),List(),List(),ArraySeq(TermsQuery(usageRights.supplier,List(AAP, Alamy, Allstar Picture Library, AP, EPA, Getty Images, PA, Reuters, Rex Features, Ronald Grant Archive, Action Images),None,None,None,None,None), TermsQuery(usageRights.category,List(handout, PR Image, screengrab, social-media, commissioned-agency, Bylines, staff-photographer, contract-photographer, commissioned-photographer, creative-commons, guardian-witness, pool, crown-copyright, obituary, contract-illustrator, commissioned-illustrator, staff-illustrator, composite, public-domain),None,None,None,None,None))), BoolQuery(None,None,None,None,List(),Vector(RangeQuery(uploadTime,None,None,None,None,Some(2026-06-01T10:54:43.652Z),None,None,None,None)),List(),List())),List(),List()) + + filterOpt + } } diff --git a/media-api/app/lib/querysyntax/QuerySyntax.scala b/media-api/app/lib/querysyntax/QuerySyntax.scala index cb62b5346e..73dfc89080 100644 --- a/media-api/app/lib/querysyntax/QuerySyntax.scala +++ b/media-api/app/lib/querysyntax/QuerySyntax.scala @@ -33,6 +33,7 @@ class QuerySyntax(val input: ParserInput) extends Parser with ImageFields { def Filter = rule { HasMatch ~> Match | IsMatch ~> Match | + SimilarMatch ~> Match | DateConstraintMatch | DateRangeMatch ~> Match | AtMatch | FileTypeMatch ~> Match | @@ -51,6 +52,11 @@ class QuerySyntax(val input: ParserInput) extends Parser with ImageFields { def IsFieldName = rule { "is" } def IsMatchValue = rule { QuotedString ~> IsValue | String ~> IsValue } + def SimilarMatch = rule { SimilarMatchField ~ ':' ~ SimilarMatchValue } + def SimilarMatchField = rule { capture(SimilarFieldName) ~> (_ => SimilarField) } + def SimilarFieldName = rule { "similar" } + def SimilarMatchValue = rule { QuotedString ~> SimilarValue | String ~> SimilarValue } + def NestedMatch = rule { ParentField ~ "@" ~ NestedField ~ ':' ~ ExactMatchValue } def NestedDateMatch = rule { ParentField ~ "@" ~ DateConstraintMatch ~> ( (parentField: Field, dateMatch: Match) => { diff --git a/media-api/app/lib/querysyntax/model.scala b/media-api/app/lib/querysyntax/model.scala index fe22886154..871cb79e5e 100644 --- a/media-api/app/lib/querysyntax/model.scala +++ b/media-api/app/lib/querysyntax/model.scala @@ -15,6 +15,7 @@ final case class SingleField(name: String) extends Field final case class MultipleField(names: List[String]) extends Field final case object HasField extends Field final case object IsField extends Field +final case object SimilarField extends Field sealed trait Value final case class Words(string: String) extends Value @@ -23,3 +24,4 @@ final case class Date(date: DateTime) extends Value final case class DateRange(startDate: DateTime, endDate: DateTime) extends Value final case class HasValue(string: String) extends Value final case class IsValue(string: String) extends Value +final case class SimilarValue(string: String) extends Value diff --git a/media-api/test/lib/querysyntax/ParserTest.scala b/media-api/test/lib/querysyntax/ParserTest.scala index 4e5aa9ddbf..4cfea97da1 100644 --- a/media-api/test/lib/querysyntax/ParserTest.scala +++ b/media-api/test/lib/querysyntax/ParserTest.scala @@ -486,6 +486,24 @@ class ParserTest extends AnyFunSpec with Matchers with BeforeAndAfter with Image } } + describe("similar filter") { + it("should parse similar queries") { + Parser.run("similar:abc123") should be (List( + Match(SimilarField, SimilarValue("abc123")), + ) ++ standardNegations + ) + } + + it("should parse similar queries with other filters and text") { + Parser.run("similar:abc123 fileType:jpeg hello") should be (List( + Match(SimilarField, SimilarValue("abc123")), + Match(SingleField(getFieldPath("mimeType")), Words("image/jpeg")), + Match(AnyField, Words("hello")), + ) ++ standardNegations + ) + } + } + describe("fileType filter") { it("should find jpegs images") { Parser.run("fileType:jpeg") should be (List( From 98a1ee79b0c199de36ddcdbc28b420362d699343 Mon Sep 17 00:00:00 2001 From: Ellen Muller Date: Tue, 2 Jun 2026 16:02:02 +0100 Subject: [PATCH 03/20] add chip filter opt --- media-api/app/controllers/MediaApi.scala | 18 ++++++++++++++++-- .../app/lib/elasticsearch/ElasticSearch.scala | 4 +++- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/media-api/app/controllers/MediaApi.scala b/media-api/app/controllers/MediaApi.scala index 81b0c920e4..b975de07dd 100644 --- a/media-api/app/controllers/MediaApi.scala +++ b/media-api/app/controllers/MediaApi.scala @@ -16,9 +16,10 @@ import com.gu.mediaservice.lib.play.RequestLoggingFilter import com.gu.mediaservice.model._ import com.gu.mediaservice.syntax.MessageSubjects import com.gu.mediaservice.{GridClient, JsonDiff} +import com.sksamuel.elastic4s.requests.searches.queries.Query import lib._ import lib.elasticsearch._ -import lib.querysyntax.{AnyField, Match, Parser, Phrase, SimilarField, SimilarValue, Words} +import lib.querysyntax.{AnyField, Condition, Match, Parser, Phrase, SimilarField, SimilarValue, Words} import org.apache.http.entity.ContentType import org.apache.pekko.stream.scaladsl.StreamConverters import org.http4s.UriTemplate @@ -595,6 +596,14 @@ class MediaApi( if (queries.nonEmpty) Some(queries.mkString(" ")) else None } + def extractFilterConditions(params: SearchParams): List[Condition] = { + params.structuredQuery.filter { + case Match(AnyField, _) => false + case Match(SimilarField, _) => false + case _ => true + } + } + def emptyAiSearchResponse = Future.successful(respondCollection(List.empty[EmbeddedEntity[JsValue]], Some(0), Some(0), None, List())) @@ -653,6 +662,11 @@ class MediaApi( // load fires and both callers receive the same Future. val embeddingFuture = embeddingCache.get(cacheKey) + val filterConditions = extractFilterConditions(params) + val chipFilterOpt: Option[Query] = + if (filterConditions.nonEmpty) Some(elasticSearch.queryBuilder.makeQuery(filterConditions)) + else None + logger.info(markers, s"vecWeight for query '$semanticQuery' is $weight") for { embedding <- embeddingFuture @@ -662,7 +676,7 @@ class MediaApi( k = k, numCandidates = Math.max(k * 2, 100), vecWeight = weight, - params = params +// filterOpt = filterOpt ) } yield searchResults } diff --git a/media-api/app/lib/elasticsearch/ElasticSearch.scala b/media-api/app/lib/elasticsearch/ElasticSearch.scala index aead53a7b1..af47c2b8a4 100644 --- a/media-api/app/lib/elasticsearch/ElasticSearch.scala +++ b/media-api/app/lib/elasticsearch/ElasticSearch.scala @@ -265,13 +265,15 @@ class ElasticSearch( k: Int, numCandidates: Int, vecWeight: Double, - params: SearchParams +// filterOpt: Option[Query] )( implicit ex: ExecutionContext, logMarker: LogMarker ): Future[SearchResults] = { val queryEmbeddingDouble: List[Double] = queryEmbedding.map(_.toDouble) + + for { maxScore <- fetchMaxBm25Score(query) searchRequest = makeHybridSearchRequest(query, queryEmbeddingDouble, k, numCandidates, vecWeight, maxScore) From 3f32decdc00665ca940a31884ebe6e6af9042cb4 Mon Sep 17 00:00:00 2001 From: Ellen Muller Date: Tue, 2 Jun 2026 17:06:37 +0100 Subject: [PATCH 04/20] wire filteropt through to elasticsearch query --- media-api/app/controllers/MediaApi.scala | 34 +++++++++++++++---- .../app/lib/elasticsearch/ElasticSearch.scala | 30 +++++++++------- 2 files changed, 46 insertions(+), 18 deletions(-) diff --git a/media-api/app/controllers/MediaApi.scala b/media-api/app/controllers/MediaApi.scala index b975de07dd..62aaac35ef 100644 --- a/media-api/app/controllers/MediaApi.scala +++ b/media-api/app/controllers/MediaApi.scala @@ -604,6 +604,29 @@ class MediaApi( } } + def combineFilters(left: Option[Query], right: Option[Query]): Option[Query] = (left, right) match { + case (Some(leftQuery), Some(rightQuery)) => Some(com.gu.mediaservice.lib.elasticsearch.filters.and(leftQuery, rightQuery)) + case (someLeft @ Some(_), None) => someLeft + case (None, someRight @ Some(_)) => someRight + case (None, None) => None + } + + def buildAiFilter(params: SearchParams): Option[Query] = { + val filterConditions = extractFilterConditions(params) + + val chipFilterOpt = + if (filterConditions.nonEmpty) Some(elasticSearch.queryBuilder.makeQuery(filterConditions)) + else None + + val requestFilterOpt = elasticSearch.queryBuilder.buildFilterOpt( + params, + elasticSearch.searchFilters, + elasticSearch.syndicationFilter + ) + + combineFilters(chipFilterOpt, requestFilterOpt) + } + def emptyAiSearchResponse = Future.successful(respondCollection(List.empty[EmbeddedEntity[JsValue]], Some(0), Some(0), None, List())) @@ -619,6 +642,8 @@ class MediaApi( } def semanticSearchByImage(imageId: String, k: Int, params: SearchParams): Future[SearchResults] = { + val filterOpt = buildAiFilter(params) + for { maybeImage <- elasticSearch.getImageById(imageId) maybeEmbedding = maybeImage @@ -629,7 +654,7 @@ class MediaApi( searchResults <- maybeEmbedding match { // If we have an embedding, perform the KNN search. If not, return an empty result set. case Some(embedding) => - elasticSearch.knnSearch(embedding, k = k, numCandidates = Math.max(k * 2, 100), params) + elasticSearch.knnSearch(embedding, k = k, numCandidates = Math.max(k * 2, 100), filterOpt = filterOpt) case None => Future.successful(SearchResults(Nil, total = 0, extraCounts = None)) } @@ -662,10 +687,7 @@ class MediaApi( // load fires and both callers receive the same Future. val embeddingFuture = embeddingCache.get(cacheKey) - val filterConditions = extractFilterConditions(params) - val chipFilterOpt: Option[Query] = - if (filterConditions.nonEmpty) Some(elasticSearch.queryBuilder.makeQuery(filterConditions)) - else None + val filterOpt = buildAiFilter(params) logger.info(markers, s"vecWeight for query '$semanticQuery' is $weight") for { @@ -676,7 +698,7 @@ class MediaApi( k = k, numCandidates = Math.max(k * 2, 100), vecWeight = weight, -// filterOpt = filterOpt + filterOpt = filterOpt ) } yield searchResults } diff --git a/media-api/app/lib/elasticsearch/ElasticSearch.scala b/media-api/app/lib/elasticsearch/ElasticSearch.scala index af47c2b8a4..373c5475f9 100644 --- a/media-api/app/lib/elasticsearch/ElasticSearch.scala +++ b/media-api/app/lib/elasticsearch/ElasticSearch.scala @@ -178,18 +178,18 @@ class ElasticSearch( } } - def knnSearch(queryEmbedding: List[Float], k: Int, numCandidates: Int, params: SearchParams) + def knnSearch(queryEmbedding: List[Float], k: Int, numCandidates: Int, filterOpt: Option[Query]) (implicit ex: ExecutionContext, logMarker: LogMarker): Future[SearchResults] = { -// TODO add relevant search parameters to the search - logger.info(logMarker, s"$params still need to do something with the params here") - val knn = Knn("embedding.cohereEmbedV4.image") .queryVector(queryEmbedding.map(_.toDouble)) .k(k) .numCandidates(numCandidates) + val baseQuery = BoolQuery().should(Seq(knn)) + val filteredQuery = filterOpt.map(filter => BoolQuery().must(baseQuery).filter(filter)).getOrElse(baseQuery) + val searchRequest = ElasticDsl.search(imagesCurrentAlias) - .knn(knn) + .bool(filteredQuery) .size(k) executeAndLog(withSearchQueryTimeout(searchRequest), "knn search").map { r => @@ -214,9 +214,12 @@ class ElasticSearch( // 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. - private def fetchMaxBm25Score(query: String)(implicit ex: ExecutionContext, logMarker: LogMarker): Future[Double] = { + private def fetchMaxBm25Score(query: String, filterOpt: Option[Query])(implicit ex: ExecutionContext, logMarker: LogMarker): Future[Double] = { + val baseQuery = createMultiMatchQuery(query) + val filteredQuery = filterOpt.map(filter => boolQuery().must(baseQuery).filter(filter)).getOrElse(baseQuery) + val maxScoreRequest = ElasticDsl.search(imagesCurrentAlias) - .query(createMultiMatchQuery(query)) + .query(filteredQuery) executeAndLog(withSearchQueryTimeout(maxScoreRequest), "max BM25 score").map { r => logger.info(logMarker, s"Max BM25 score for query '$query' is ${r.result.hits.maxScore}") @@ -230,7 +233,8 @@ class ElasticSearch( k: Int, numCandidates: Int, vecWeight: Double, - maxScore: Double + maxScore: Double, + filterOpt: Option[Query] )(implicit logMarker: LogMarker): SearchRequest = { val knn = Knn("embedding.cohereEmbedV4.image") .queryVector(queryEmbedding) @@ -253,9 +257,11 @@ class ElasticSearch( logger.info(logMarker, s"Scaling factor for BM25 score is $scalingFactor, multi-match boost is $multiMatchBoost") val multiMatchQuery = createMultiMatchQuery(query, boost = Some(multiMatchBoost)) + val baseQuery = BoolQuery().should(Seq(multiMatchQuery, knn)) + val filteredQuery = filterOpt.map(filter => BoolQuery().must(baseQuery).filter(filter)).getOrElse(baseQuery) ElasticDsl.search(imagesCurrentAlias) - .bool(BoolQuery().should(Seq(multiMatchQuery, knn))) + .bool(filteredQuery) .size(k) } @@ -265,7 +271,7 @@ class ElasticSearch( k: Int, numCandidates: Int, vecWeight: Double, -// filterOpt: Option[Query] + filterOpt: Option[Query] )( implicit ex: ExecutionContext, logMarker: LogMarker @@ -275,8 +281,8 @@ class ElasticSearch( for { - maxScore <- fetchMaxBm25Score(query) - searchRequest = makeHybridSearchRequest(query, queryEmbeddingDouble, k, numCandidates, vecWeight, maxScore) + maxScore <- fetchMaxBm25Score(query, filterOpt) + searchRequest = makeHybridSearchRequest(query, queryEmbeddingDouble, k, numCandidates, vecWeight, maxScore, filterOpt) result <- executeAndLog(withSearchQueryTimeout(searchRequest), "hybrid search") } yield { val imageHits = result.result.hits.hits.map(resolveHit).toSeq.flatten.map(i => (i.instance.id, i)) From efbeb337ee3236e1baae22b1dd30a92b5e754a63 Mon Sep 17 00:00:00 2001 From: Ellen Muller Date: Wed, 3 Jun 2026 14:09:39 +0100 Subject: [PATCH 05/20] require a semantic text query in semantic mode --- kahuna/public/js/search/results.js | 3 +- .../js/search/structured-query/syntax.ts | 32 +++++++++ media-api/app/controllers/MediaApi.scala | 65 +++++++++---------- 3 files changed, 66 insertions(+), 34 deletions(-) diff --git a/kahuna/public/js/search/results.js b/kahuna/public/js/search/results.js index 1268fd9a9e..783132bd3b 100644 --- a/kahuna/public/js/search/results.js +++ b/kahuna/public/js/search/results.js @@ -1,6 +1,7 @@ import angular from 'angular'; import Rx from 'rx'; import moment from 'moment'; +import {hasPositiveAiTextQuery} from './structured-query/syntax'; import '../services/scroll-position'; import '../services/panel'; @@ -204,7 +205,7 @@ results.controller('SearchResultsCtrl', [ ctrl.newImagesCount = 0; ctrl.newImagesLastCheckedMoment = moment(); - ctrl.needsQuery = $stateParams.useAISearch && (!$stateParams.query || !$stateParams.query.trim()); + ctrl.needsQuery = $stateParams.useAISearch && !hasPositiveAiTextQuery($stateParams.query); // Map to track image->position and help remove duplicates let imagesPositions; diff --git a/kahuna/public/js/search/structured-query/syntax.ts b/kahuna/public/js/search/structured-query/syntax.ts index 46bf677809..e707c17fa0 100644 --- a/kahuna/public/js/search/structured-query/syntax.ts +++ b/kahuna/public/js/search/structured-query/syntax.ts @@ -30,6 +30,37 @@ type StructuredQueryText = { export type StructuredQuery = (StructuredQueryFilter | StructuredQueryText)[]; +// This is intentionally lower-level than structureQuery(). The UI-oriented +// structureQuery() merges all text tokens into a single text chip and preserves +// negation by prefixing the text value with '-', which loses the distinction +// between positive free text and negated-only text. AI search needs that +// distinction so the frontend matches the backend rule: only positive unfielded +// text counts as a valid semantic query. +export function hasPositiveAiTextQuery(query: string) { + let m; + if (query === undefined) { + return false; + } + + parserRe.lastIndex = 0; + while ((m = parserRe.exec(query)) !== null) { + const sign = m[1]; + const field = m[2] || m[3] || m[4]; + const symbol = m[5] || m[6]; + const text = m[10] || m[11] || m[12]; + const key = { + '#': 'label', + '~': 'collection' + }[symbol] || field; + + if (!key && sign !== '-' && falsyValuesToEmptyString(text).trim()) { + return true; + } + } + + return false; +} + // TODO: expose the server-side query parser via an API instead of // replicating it poorly here export function structureQuery(query: string) { @@ -39,6 +70,7 @@ export function structureQuery(query: string) { if (query === undefined) { return struct; } + parserRe.lastIndex = 0; while ((m = parserRe.exec(query)) !== null) { const sign = m[1]; const field = m[2] || m[3] || m[4]; diff --git a/media-api/app/controllers/MediaApi.scala b/media-api/app/controllers/MediaApi.scala index 62aaac35ef..40d8a9b7c4 100644 --- a/media-api/app/controllers/MediaApi.scala +++ b/media-api/app/controllers/MediaApi.scala @@ -664,43 +664,42 @@ class MediaApi( def semanticSearchByText(query: String, k: Int, params: SearchParams): Future[SearchResults] = { // Separate the chips from the main query text // So that we can embed just the query text - val semanticQuery = extractSemanticTextQuery(params: SearchParams) -// TODO remove this getorelse, handle the empty case properly - .getOrElse { - logger.info(logMarker, s"No semantic query found in structured query, falling back to embedding the full query text: $query") - query - } + extractSemanticTextQuery(params: SearchParams) match { + case None => + logger.info(logMarker, s"No semantic query found in structured query; returning no AI results") + Future.successful(SearchResults(Nil, total = 0, extraCounts = None)) + case Some(semanticQuery) => + // Normalise key so that "Dogs" and "dogs " share a cache entry. + val cacheKey = semanticQuery.trim.toLowerCase + val markers = logMarker ++ Map("query" -> semanticQuery) + + if (embeddingCache.getIfPresent(cacheKey).isDefined) { + logger.info(markers, s"AI search embedding cache hit query=$semanticQuery") + } else { + logger.info(markers, s"AI search embedding cache miss query=$semanticQuery") + } - // Normalise key so that "Dogs" and "dogs " share a cache entry. - val cacheKey = semanticQuery.trim.toLowerCase - val markers = logMarker ++ Map("query" -> semanticQuery) + val weight = params.vecWeight.getOrElse(1.0) - if (embeddingCache.getIfPresent(cacheKey).isDefined) { - logger.info(markers, s"AI search embedding cache hit query=$semanticQuery") - } else { - logger.info(markers, s"AI search embedding cache miss query=$semanticQuery") - } - - val weight = params.vecWeight.getOrElse(1.0) + // 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) - // 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) + val filterOpt = buildAiFilter(params) - val filterOpt = buildAiFilter(params) - - logger.info(markers, s"vecWeight for query '$semanticQuery' is $weight") - for { - embedding <- embeddingFuture - searchResults <- elasticSearch.hybridSearch( - query = semanticQuery, - queryEmbedding = embedding, - k = k, - numCandidates = Math.max(k * 2, 100), - vecWeight = weight, - filterOpt = filterOpt - ) - } yield searchResults + logger.info(markers, s"vecWeight for query '$semanticQuery' is $weight") + for { + embedding <- embeddingFuture + searchResults <- elasticSearch.hybridSearch( + query = semanticQuery, + queryEmbedding = embedding, + k = k, + numCandidates = Math.max(k * 2, 100), + vecWeight = weight, + filterOpt = filterOpt + ) + } yield searchResults + } } def performAiSearchAndRespond(query: String, params: SearchParams): Future[Result] = { From 6ef668b9bbfad680011aa80e548d60768b392815 Mon Sep 17 00:00:00 2001 From: Ellen Muller Date: Wed, 3 Jun 2026 14:15:24 +0100 Subject: [PATCH 06/20] tidy up extracting semantic text query --- media-api/app/controllers/MediaApi.scala | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/media-api/app/controllers/MediaApi.scala b/media-api/app/controllers/MediaApi.scala index 40d8a9b7c4..20c5ca8597 100644 --- a/media-api/app/controllers/MediaApi.scala +++ b/media-api/app/controllers/MediaApi.scala @@ -587,13 +587,13 @@ class MediaApi( } def extractSemanticTextQuery(params: SearchParams): Option[String] = { -// logger.info(logMarker, s"Extracting semantic query from structured query: ${params.structuredQuery}") - val queries = params.structuredQuery.collect { + val queries = params.structuredQuery.collect { case Match(AnyField, Words(value)) => value case Match(AnyField, Phrase(value)) => value - } - logger.info(logMarker, s"Extracted semantic query from structured query: ${queries.mkString(" ")}") - if (queries.nonEmpty) Some(queries.mkString(" ")) else None + }.map(_.trim).filter(_.nonEmpty) + val semanticQuery = queries.mkString(" ") + logger.info(logMarker, s"Extracted semantic query from structured query: $semanticQuery") + Option.when(semanticQuery.nonEmpty)(semanticQuery) } def extractFilterConditions(params: SearchParams): List[Condition] = { From 21cbcf9672c674373d6b0d70846a5d52ce40d4db Mon Sep 17 00:00:00 2001 From: Ellen Muller Date: Wed, 3 Jun 2026 16:12:54 +0100 Subject: [PATCH 07/20] add case where there's a similar search + semantic text query --- kahuna/public/js/search/results.js | 7 +++-- .../js/search/structured-query/syntax.ts | 28 +++++++++++++++++++ media-api/app/controllers/MediaApi.scala | 26 +++++++++++++---- 3 files changed, 53 insertions(+), 8 deletions(-) diff --git a/kahuna/public/js/search/results.js b/kahuna/public/js/search/results.js index 783132bd3b..d93720ad14 100644 --- a/kahuna/public/js/search/results.js +++ b/kahuna/public/js/search/results.js @@ -1,7 +1,7 @@ import angular from 'angular'; import Rx from 'rx'; import moment from 'moment'; -import {hasPositiveAiTextQuery} from './structured-query/syntax'; +import {hasPositiveAiTextQuery, hasSimilarAndPositiveAiTextQuery} from './structured-query/syntax'; import '../services/scroll-position'; import '../services/panel'; @@ -205,7 +205,10 @@ results.controller('SearchResultsCtrl', [ ctrl.newImagesCount = 0; ctrl.newImagesLastCheckedMoment = moment(); - ctrl.needsQuery = $stateParams.useAISearch && !hasPositiveAiTextQuery($stateParams.query); + ctrl.needsQuery = $stateParams.useAISearch && ( + !hasPositiveAiTextQuery($stateParams.query) || + hasSimilarAndPositiveAiTextQuery($stateParams.query) + ); // Map to track image->position and help remove duplicates let imagesPositions; diff --git a/kahuna/public/js/search/structured-query/syntax.ts b/kahuna/public/js/search/structured-query/syntax.ts index e707c17fa0..72c143957f 100644 --- a/kahuna/public/js/search/structured-query/syntax.ts +++ b/kahuna/public/js/search/structured-query/syntax.ts @@ -61,6 +61,34 @@ export function hasPositiveAiTextQuery(query: string) { return false; } +export function hasSimilarAndPositiveAiTextQuery(query: string) { + return hasValidSimilarQuery(query) && hasPositiveAiTextQuery(query); +} + +function hasValidSimilarQuery(query: string) { + let m; + if (query === undefined) { + return false; + } + + parserRe.lastIndex = 0; + while ((m = parserRe.exec(query)) !== null) { + const field = m[2] || m[3] || m[4]; + const symbol = m[5] || m[6]; + const value = m[7] || m[8] || m[9]; + const key = { + '#': 'label', + '~': 'collection' + }[symbol] || field; + + if (key === 'similar' && falsyValuesToEmptyString(value).trim()) { + return true; + } + } + + return false; +} + // TODO: expose the server-side query parser via an API instead of // replicating it poorly here export function structureQuery(query: string) { diff --git a/media-api/app/controllers/MediaApi.scala b/media-api/app/controllers/MediaApi.scala index 20c5ca8597..c4c8e4f6af 100644 --- a/media-api/app/controllers/MediaApi.scala +++ b/media-api/app/controllers/MediaApi.scala @@ -627,6 +627,15 @@ class MediaApi( combineFilters(chipFilterOpt, requestFilterOpt) } + def hasSimilarAndSemanticText(params: SearchParams): Boolean = { + val hasSimilar = params.structuredQuery.exists { + case Match(SimilarField, SimilarValue(imageId)) if imageId.nonEmpty => true + case _ => false + } + + hasSimilar && extractSemanticTextQuery(params).nonEmpty + } + def emptyAiSearchResponse = Future.successful(respondCollection(List.empty[EmbeddedEntity[JsValue]], Some(0), Some(0), None, List())) @@ -703,13 +712,18 @@ class MediaApi( } def performAiSearchAndRespond(query: String, params: SearchParams): Future[Result] = { - val k = config.aiSearchResultLimit - val searchResultsFuture = parseAiSearchMode(query, params) match { - case SimilarSearch(imageId) => semanticSearchByImage(imageId, k, params) - case TextSearch(textQuery) => semanticSearchByText(textQuery, k, params) - } + if (hasSimilarAndSemanticText(params)) { + logger.info(logMarker, s"AI query contains both similar: and positive free text; returning no AI results") + emptyAiSearchResponse + } else { + val k = config.aiSearchResultLimit + val searchResultsFuture = parseAiSearchMode(query, params) match { + case SimilarSearch(imageId) => semanticSearchByImage(imageId, k, params) + case TextSearch(textQuery) => semanticSearchByText(textQuery, k, params) + } - searchResultsFuture.map(aiSearchResponseFromResults) + searchResultsFuture.map(aiSearchResponseFromResults) + } } if (_searchParams.useAISearch.contains(true)) { From 3246742f9d61f8a64644ac99ac7ea1708b77b2f7 Mon Sep 17 00:00:00 2001 From: Ellen Muller Date: Wed, 3 Jun 2026 16:38:56 +0100 Subject: [PATCH 08/20] filter at knn stage to maximise knn results --- media-api/app/lib/elasticsearch/ElasticSearch.scala | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/media-api/app/lib/elasticsearch/ElasticSearch.scala b/media-api/app/lib/elasticsearch/ElasticSearch.scala index 373c5475f9..e0a81f2f2e 100644 --- a/media-api/app/lib/elasticsearch/ElasticSearch.scala +++ b/media-api/app/lib/elasticsearch/ElasticSearch.scala @@ -184,12 +184,10 @@ class ElasticSearch( .queryVector(queryEmbedding.map(_.toDouble)) .k(k) .numCandidates(numCandidates) - - val baseQuery = BoolQuery().should(Seq(knn)) - val filteredQuery = filterOpt.map(filter => BoolQuery().must(baseQuery).filter(filter)).getOrElse(baseQuery) + .filter(filterOpt.getOrElse(matchAllQuery())) val searchRequest = ElasticDsl.search(imagesCurrentAlias) - .bool(filteredQuery) + .knn(knn) .size(k) executeAndLog(withSearchQueryTimeout(searchRequest), "knn search").map { r => @@ -241,6 +239,7 @@ class ElasticSearch( .k(k) .numCandidates(numCandidates) .boost(if (vecWeight > 0.0) 1.0 else 0.0) + .filter(filterOpt.getOrElse(matchAllQuery())) val lexicalWeight = 1.0 - vecWeight @@ -249,9 +248,9 @@ class ElasticSearch( // 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 + // 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 50db9a580a3060496a45367ceb76dadb8eb690d0 Mon Sep 17 00:00:00 2001 From: Ellen Muller Date: Wed, 3 Jun 2026 16:46:36 +0100 Subject: [PATCH 09/20] don't collapse filters when in ai mode --- kahuna/public/js/components/gr-chips/gr-chips.js | 2 +- kahuna/public/js/search/query.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/kahuna/public/js/components/gr-chips/gr-chips.js b/kahuna/public/js/components/gr-chips/gr-chips.js index 5911eee453..d3db7528aa 100644 --- a/kahuna/public/js/components/gr-chips/gr-chips.js +++ b/kahuna/public/js/components/gr-chips/gr-chips.js @@ -27,7 +27,7 @@ grChips.controller('grChipsCtrl', ['$scope', function($scope) { $grChipsCtrl.configureNgModel = function(ngModelCtrl, onChangeExpr, autoCompleteExpr, validKeysExpr, autofocus, placeholder) { $grChipsCtrl.defaultPlaceholder = placeholder || 'Search for images... (type + for advanced search)'; - $grChipsCtrl.aiSearchPlaceholder = 'Search for conceptual images using AI search... (no filters available)'; + $grChipsCtrl.aiSearchPlaceholder = 'Search for conceptual images using AI search...'; $grChipsCtrl.onChange = () => onChangeExpr($scope, {$chips: $grChipsCtrl.items}); diff --git a/kahuna/public/js/search/query.html b/kahuna/public/js/search/query.html index 8cfde8b115..a01d32be66 100644 --- a/kahuna/public/js/search/query.html +++ b/kahuna/public/js/search/query.html @@ -30,7 +30,7 @@ info_outline -
+