Skip to content

Add chips and filtering to AI Search#4752

Merged
ellenmuller merged 21 commits into
mainfrom
em-semantic-search-filtering
Jun 10, 2026
Merged

Add chips and filtering to AI Search#4752
ellenmuller merged 21 commits into
mainfrom
em-semantic-search-filtering

Conversation

@ellenmuller

@ellenmuller ellenmuller commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

What does this change?

This PR makes AI search respect Grid’s existing chip/filter behaviour and adds structured support for similar: queries.

Previously, 'Use AI search' embedded the raw query, including any chips, so structured chips such as file type and other filters did not work as one might expect. This branch changes the frontend and backend flow so AI search separates semantic text from structured filters, applies those filters to Elasticsearch, and handles similar: as part of the structured query model rather than via ad hoc string parsing.

Changes

  • Added similarity 'chip' to the structured query parser.
  • Switched AI mode detection to use structured query parsing.
  • Split AI queries into:
    • semantic text to embed
    • structured filter conditions to apply to Elasticsearch
  • Reused QueryBuilder logic so AI mode inherits the same chip semantics as standard search.
  • Wired filter application through hybrid search and similar-image KNN search:
    • added inline KNN filtering to improve recall for filtered similar-image searches.
    • kept top-level query filtering so final hybrid results still obey filters on the lexical branch.
  • Added backend guards for invalid AI queries:
    • chips-only AI text queries just say that the user needs to add a query
    • mixed similar: plus positive semantic text also doesn't work
  • Added matching frontend guards so unsupported AI queries don't trickle end up in backend. (Copilot helped here!)
  • Updated the search UI so filters are visible again in AI mode, except for sorting date. (Copilot helped here!)

How should a reviewer test this change?

How can success be measured?

Who should look at this?

Tested? Documented?

  • locally by committer
  • locally by Guardian reviewer
  • on the Guardian's TEST environment
  • relevant documentation added or amended (if needed)

@ellenmuller ellenmuller added the feature Departmental tracking: work on a new feature label Jun 2, 2026
@github-actions

github-actions Bot commented Jun 2, 2026

Copy link
Copy Markdown

@ellenmuller ellenmuller changed the title Em semantic search filtering Add chips and filtering to 'Use AI Search` Jun 5, 2026
@ellenmuller ellenmuller changed the title Add chips and filtering to 'Use AI Search` Add chips and filtering to 'Use AI Search' Jun 5, 2026
@ellenmuller ellenmuller changed the title Add chips and filtering to 'Use AI Search' Add chips and filtering to AI Search Jun 5, 2026
@ellenmuller ellenmuller marked this pull request as ready for review June 5, 2026 10:33
@ellenmuller ellenmuller requested a review from a team as a code owner June 5, 2026 10:33

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates Grid’s AI search flow so it respects the existing structured query “chips”/filters, and adds first-class structured parsing support for similar: queries. It splits AI queries into (a) semantic free text to embed and (b) structured filter conditions to apply to Elasticsearch, reusing existing query-building/filter logic so AI mode behaves consistently with standard search.

Changes:

  • Added similar: as a structured query filter in the server-side parser + model, with corresponding parser tests.
  • Introduced AiQueryParts to split structured queries into semantic text, filter conditions, and optional similar-image ID; updated Media API AI search to use these parts and apply filters to hybrid/KNN queries.
  • Updated Kahuna UI to re-enable filters in AI mode and add frontend guards for unsupported AI query shapes.

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
media-api/test/lib/querysyntax/ParserTest.scala Adds coverage to ensure similar: is parsed into structured conditions.
media-api/app/lib/querysyntax/QuerySyntax.scala Extends parser grammar to recognize similar: as a filter.
media-api/app/lib/querysyntax/model.scala Adds SimilarField / SimilarValue to the structured query model.
media-api/app/lib/elasticsearch/QueryBuilder.scala Handles SimilarField explicitly and centralizes construction of request-level filter queries via buildFilterOpt.
media-api/app/lib/elasticsearch/ElasticSearchModel.scala Adds AiQueryParts and exposes SearchParams.aiQueryParts for AI query splitting.
media-api/app/lib/elasticsearch/ElasticSearch.scala Threads filterOpt through KNN + hybrid queries; uses QueryBuilder.buildFilterOpt during standard search.
media-api/app/controllers/MediaApi.scala Switches AI mode detection and filtering to use SearchParams.aiQueryParts; blocks unsupported similar:+semantic combinations.
kahuna/public/js/search/structured-query/syntax.ts Adds low-level query validation helpers for AI mode (positive unfielded text vs similar:).
kahuna/public/js/search/results.js Updates AI “needs query” guard logic using the new structured-query helpers.
kahuna/public/js/search/query.html Makes filter UI visible in AI mode (except sort UI remains hidden for AI).
kahuna/public/js/components/gr-chips/gr-chips.js Updates AI placeholder text to reflect that filters are now available.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread media-api/app/controllers/MediaApi.scala Outdated
Comment on lines 290 to 294
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)

Comment thread kahuna/public/js/search/structured-query/syntax.ts Outdated
Comment thread kahuna/public/js/search/structured-query/syntax.ts Outdated
Comment thread kahuna/public/js/search/structured-query/syntax.ts Outdated
Comment thread kahuna/public/js/search/results.js Outdated
Comment on lines +208 to +211
ctrl.needsQuery = $stateParams.useAISearch && (
!hasPositiveAiTextQuery($stateParams.query) ||
hasSimilarAndPositiveAiTextQuery($stateParams.query)
);
Comment thread kahuna/public/js/search/results.js Outdated
Comment thread media-api/app/lib/elasticsearch/ElasticSearchModel.scala
ellenmuller and others added 2 commits June 5, 2026 12:23
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
@joelochlann

joelochlann commented Jun 5, 2026

Copy link
Copy Markdown
Member

This looks really nice!

A first thought on the UI: now that we have filters & chips, I didn't expect the search to be blank until I enter a query. I thought that I would be able to mess around with filters, seeing how it shapes my dataset and the number of hits etc, before I added in the AI search layer on top.

I feel that this would make it clearer what's actually happening with the pre-filtering, especially if we give the right information in the UI.

Even if we think we want it blank until it enters a query, I think there has to be some information about what's going on with the pre-filtering + knn, otherwise always seeing "200 results" no matter what I do with the filters (unless I get it below 200!) is confusing.

Here's a (fairly clumsy) attempt

x images matching filters => (of which) showing 200 most similar to AI search

Screenshot 2026-06-05 at 14 45 46

@paperboyo

paperboyo commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

🎉🎉🎉 👏

I didn't expect the search to be blank until I enter a query

+1 to not blank, yeah.

Agreed signalling why 200 good idea. Changing Sort to Relevance may or (more likely) may not be enough. Maybe just crude text?

image

Whatever we decide, let’s not mimic tickers looks as these are clickable (to filter)?

Unrelatedly, I would also split More like this from the Use AI checkbox completely. Currently, this deadend is possible:

image

@joelochlann

Copy link
Copy Markdown
Member

Yeah it's tricky. The problem with "200 relevant" is we don't know that all 200 are relevant! They're just the best 200 we could manage. In fact it's very rare they will all be relevant.

@paperboyo

paperboyo commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

Yeah it's tricky. The problem with "200 relevant" is we don't know that all 200 are relevant! They're just the best 200 we could manage. In fact it's very rare they will all be relevant.

Yes, of course. I struggled the come up with a word (just like for “Sort” order: I think they should be the same word). But what matters here more than the fact that the word isn’t perfect is that it’s there at all: it signals a) it’s a bound subset b) of a corpus you can actively shape, c) has to do with this AI thing and d) is about ordering.

Especially b) (of 2,129) is particularly revealing about the very UX issue we discussed above related to post-filtering behaving counter-intuitively (versus “normal” filtering).

And 200 AI-similarity-ordered of 2,129 was too long ;-)

[EDIT: sorry deleted doubled comment]

@gu-prout

gu-prout Bot commented Jun 10, 2026

Copy link
Copy Markdown

Seen on auth, image-loader, metadata-editor, cropper, collections, kahuna (merged by @ellenmuller 9 minutes and 38 seconds ago) Please check your changes!

@gu-prout

gu-prout Bot commented Jun 10, 2026

Copy link
Copy Markdown

Seen on thrall, usage, media-api (merged by @ellenmuller 9 minutes and 47 seconds ago) Please check your changes!

@gu-prout

gu-prout Bot commented Jun 10, 2026

Copy link
Copy Markdown

Seen on leases (merged by @ellenmuller 9 minutes and 51 seconds ago) Please check your changes!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants