Skip to content

Commit 8cf74cf

Browse files
committed
go back to unpaginated
1 parent 16199c5 commit 8cf74cf

4 files changed

Lines changed: 75 additions & 156 deletions

File tree

kahuna/public/js/search/results.js

Lines changed: 28 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -229,55 +229,11 @@ results.controller('SearchResultsCtrl', [
229229
lastSearchFirstResultTime = undefined;
230230
}
231231

232-
// Detect whether this results page is using semantic search.
233-
// We branch the initial loading behaviour because AI search should
234-
// bootstrap with a real first page of results rather than the
235-
// normal `length: 1` probe used by lexical search.
236-
const isAiSearch = !!$stateParams.useAISearch;
237-
238-
// Shared helper for writing returned images into both the sparse
239-
// backing array (`imagesAll`) and the reactive `results` list.
240-
// `start` is the offset of the returned page in the overall result set.
241-
function applyLoadedImages(images, start = 0) {
242-
images.data.forEach((image, index) => {
243-
const position = index + start;
244-
const imageId = image.data.id;
245-
246-
// If image already present in results at a
247-
// different position (result set shifted due to
248-
// items being spliced in or deleted?), get rid of
249-
// item at its previous position to avoid
250-
// duplicates
251-
const existingPosition = imagesPositions.get(imageId);
252-
if (angular.isDefined(existingPosition) &&
253-
existingPosition !== position) {
254-
$log.info(`Detected duplicate image ${imageId}, ` +
255-
`old ${existingPosition}, new ${position}`);
256-
delete ctrl.imagesAll[existingPosition];
257-
258-
results.set(existingPosition, undefined);
259-
}
260-
261-
ctrl.imagesAll[position] = image;
262-
imagesPositions.set(imageId, position);
263-
264-
results.set(position, image);
265-
});
266-
// images should not contain any 'holes'
267-
ctrl.images = compact(ctrl.imagesAll);
268-
}
269-
270232
// Initial search to find upper `until` boundary of result set
271233
// (i.e. the uploadTime of the newest result in the set)
272234

273235
// TODO: avoid this initial search (two API calls to init!)
274-
// For lexical search we keep the old lightweight probe (`length: 1`) so
275-
// we can establish totals cheaply before lazy-loading visible ranges.
276-
// For AI search that probe is harmful because it makes the backend ask
277-
// KNN for only one neighbour, which in turn poisons the displayed total.
278-
// So AI search performs a normal first-page request here instead.
279-
const initialSearch = isAiSearch ? search() : search({length: 1, orderBy: 'newest'});
280-
ctrl.searched = initialSearch.then(function(images) {
236+
ctrl.searched = search({length: 1, orderBy: 'newest'}).then(function(images) {
281237
ctrl.totalResults = images.total;
282238
// FIXME: https://github.com/argo-rest/theseus has forced us to co-opt the actions field for this
283239
ctrl.tickerCounts = images.$response?.$$state?.value?.actions?.tickerCounts;
@@ -301,16 +257,8 @@ results.controller('SearchResultsCtrl', [
301257

302258
notificationMessages(ctrl.extendedSortProps, images.total);
303259

304-
// Initialise the map before we merge in either the initial AI page or
305-
// any later lazy-loaded ranges.
306260
imagesPositions = new Map();
307261

308-
if (isAiSearch) {
309-
// Seed the sparse result arrays with the first AI page immediately.
310-
// This keeps the match count and initially rendered images aligned.
311-
applyLoadedImages(images);
312-
}
313-
314262
checkForNewImages();
315263

316264
// Keep track of time of the latest result for all
@@ -334,13 +282,35 @@ results.controller('SearchResultsCtrl', [
334282
});
335283

336284
ctrl.loadRange = function(start, end) {
337-
// Lazy-table gives us an inclusive start/end pair, so convert it to
338-
// the page size expected by the API.
339285
const length = end - start + 1;
340286
search({offset: start, length: length, countAll: false}).then(images => {
341-
// Merge the fetched page into the results array at the correct offset,
342-
// deduplicating any images that may have shifted position since the initial load.
343-
applyLoadedImages(images, start);
287+
// Update imagesAll with newly loaded images
288+
images.data.forEach((image, index) => {
289+
const position = index + start;
290+
const imageId = image.data.id;
291+
292+
// If image already present in results at a
293+
// different position (result set shifted due to
294+
// items being spliced in or deleted?), get rid of
295+
// item at its previous position to avoid
296+
// duplicates
297+
const existingPosition = imagesPositions.get(imageId);
298+
if (angular.isDefined(existingPosition) &&
299+
existingPosition !== position) {
300+
$log.info(`Detected duplicate image ${imageId}, ` +
301+
`old ${existingPosition}, new ${position}`);
302+
delete ctrl.imagesAll[existingPosition];
303+
304+
results.set(existingPosition, undefined);
305+
}
306+
307+
ctrl.imagesAll[position] = image;
308+
imagesPositions.set(imageId, position);
309+
310+
results.set(position, image);
311+
});
312+
// images should not contain any 'holes'
313+
ctrl.images = compact(ctrl.imagesAll);
344314
});
345315
};
346316

media-api/app/controllers/MediaApi.scala

Lines changed: 39 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -586,31 +586,21 @@ class MediaApi(
586586
.getOrElse(TextSearch(query))
587587
}
588588

589-
def emptyAiSearchResponse(searchParams: SearchParams) =
590-
Future.successful(
591-
respondCollection(
592-
List.empty[EmbeddedEntity[JsValue]],
593-
Some(searchParams.offset),
594-
Some(0),
595-
None,
596-
List()
597-
)
598-
)
589+
def emptyAiSearchResponse =
590+
Future.successful(respondCollection(List.empty[EmbeddedEntity[JsValue]], Some(0), Some(0), None, List()))
599591

600-
def aiSearchResponseFromResults(searchParams: SearchParams, searchResults: SearchResults): Result = {
592+
def aiSearchResponseFromResults(searchResults: SearchResults): Result = {
601593
val imageEntities = searchResults.hits map (hitToImageEntity _).tupled
602594
respondCollection(
603595
data = imageEntities,
604-
offset = Some(searchParams.offset),
596+
offset = Some(0),
605597
total = Some(searchResults.total),
606598
maybeExtraCounts = None,
607599
links = Nil
608600
)
609601
}
610602

611-
def semanticSearchByImage(searchParams: SearchParams, imageId: String): Future[SearchResults] = {
612-
val semanticRetrievalWindow = Math.max(config.aiSearchSemanticRetrievalWindow, searchParams.length)
613-
val countAll = searchParams.countAll.getOrElse(true)
603+
def semanticSearchByImage(imageId: String, k: Int): Future[SearchResults] = {
614604
for {
615605
maybeImage <- elasticSearch.getImageById(imageId)
616606
maybeEmbedding = maybeImage
@@ -621,23 +611,14 @@ class MediaApi(
621611
searchResults <- maybeEmbedding match {
622612
// If we have an embedding, perform the KNN search. If not, return an empty result set.
623613
case Some(embedding) =>
624-
elasticSearch.knnSearch(
625-
embedding,
626-
k = semanticRetrievalWindow,
627-
offset = searchParams.offset,
628-
length = searchParams.length,
629-
countAll = countAll,
630-
numCandidates = Math.max(semanticRetrievalWindow * 2, 100)
631-
)
614+
elasticSearch.knnSearch(embedding, k = k, numCandidates = Math.max(k * 2, 100))
632615
case None =>
633616
Future.successful(SearchResults(Nil, total = 0, extraCounts = None))
634617
}
635618
} yield searchResults
636619
}
637620

638-
def semanticSearchByText(searchParams: SearchParams, query: String): Future[SearchResults] = {
639-
val semanticRetrievalWindow = Math.max(config.aiSearchSemanticRetrievalWindow, searchParams.length)
640-
val countAll = searchParams.countAll.getOrElse(true)
621+
def semanticSearchByText(query: String, k: Int): Future[SearchResults] = {
641622
// Normalise key so that "Dogs" and "dogs " share a cache entry.
642623
val cacheKey = query.trim.toLowerCase
643624

@@ -655,56 +636,47 @@ class MediaApi(
655636

656637
for {
657638
embedding <- embeddingFuture
658-
searchResults <- elasticSearch.knnSearch(
659-
embedding,
660-
k = semanticRetrievalWindow,
661-
offset = searchParams.offset,
662-
length = searchParams.length,
663-
countAll = countAll,
664-
numCandidates = Math.max(semanticRetrievalWindow * 2, 100)
665-
)
639+
searchResults <- elasticSearch.knnSearch(embedding, k = k, numCandidates = Math.max(k * 2, 100))
666640
} yield searchResults
667641
}
668642

669-
def performAiSearchAndRespond(searchParams: SearchParams, query: String): Future[Result] = {
643+
def performAiSearchAndRespond(query: String): Future[Result] = {
644+
val k = 200
670645
val searchResultsFuture = parseAiSearchMode(query) match {
671-
case SimilarSearch(imageId) => semanticSearchByImage(searchParams, imageId)
672-
case TextSearch(textQuery) => semanticSearchByText(searchParams, textQuery)
646+
case SimilarSearch(imageId) => semanticSearchByImage(imageId, k)
647+
case TextSearch(textQuery) => semanticSearchByText(textQuery, k)
673648
}
674649

675-
searchResultsFuture.map(aiSearchResponseFromResults(searchParams, _))
650+
searchResultsFuture.map(aiSearchResponseFromResults)
676651
}
677652

678-
val searchParams = if(canViewDeletedImages) {
679-
_searchParams.copy(uploadedBy = Some(Authentication.getIdentity(request.user)))
680-
}
681-
else {
682-
_searchParams
683-
}
684-
685-
SearchParams.validate(searchParams).fold(
686-
errors => Future.successful(respondError(UnprocessableEntity, InvalidUriParams.errorKey,
687-
errors.map(_.message).mkString(", "))
688-
),
689-
params => {
690-
if (params.useAISearch.contains(true)) {
691-
params.query match {
692-
// Short-circuit polling requests (length=0) and empty queries to avoid unnecessary Bedrock/KNN calls
693-
case _ if params.length == 0 =>
694-
emptyAiSearchResponse(params)
695-
case Some(q) if !q.isBlank =>
696-
performAiSearchAndRespond(params, q)
697-
// Empty queries do not make sense for AI search as we can
698-
// only rank results once we have a meaningful vector to compare with.
699-
// So return 0 results if the query was empty.
700-
case _ =>
701-
emptyAiSearchResponse(params)
702-
}
703-
} else {
704-
performSearchAndRespond(params)
705-
}
653+
if (_searchParams.useAISearch.contains(true)) {
654+
_searchParams.query match {
655+
// Short-circuit polling requests (length=0) and empty queries to avoid unnecessary Bedrock/KNN calls
656+
case _ if _searchParams.length == 0 =>
657+
emptyAiSearchResponse
658+
case Some(q) if !q.isBlank =>
659+
performAiSearchAndRespond(q)
660+
// Empty queries do not make sense for AI search as we can
661+
// only rank results once we have a meaningful vector to compare with.
662+
// So return 0 results if the query was empty.
663+
case _ =>
664+
emptyAiSearchResponse
706665
}
707-
)
666+
} else {
667+
val searchParams = if (canViewDeletedImages) {
668+
_searchParams.copy(uploadedBy = Some(Authentication.getIdentity(request.user)))
669+
} else {
670+
_searchParams
671+
}
672+
SearchParams.validate(searchParams).fold(
673+
// TODO: respondErrorCollection?
674+
errors => Future.successful(respondError(UnprocessableEntity, InvalidUriParams.errorKey,
675+
errors.map(_.message).mkString(", "))
676+
),
677+
params => performSearchAndRespond(params)
678+
)
679+
}
708680
}
709681

710682
private def getImageResponseFromES(

media-api/app/lib/MediaApiConfig.scala

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,9 +76,6 @@ class MediaApiConfig(resources: GridConfigResources) extends CommonConfigWithEla
7676

7777
val queueUrl: String = stringOpt("sqs.embedder.queue.url").getOrElse("")
7878

79-
// Semantic search retrieves from a bounded nearest-neighbour window.
80-
// This keeps KNN query cost predictable and makes result totals explicitly capped.
81-
val aiSearchSemanticRetrievalWindow: Int = intOpt("ai.search.semanticRetrievalWindow").getOrElse(1000)
8279
val aiSearchEmbeddingCacheMaxSize: Int = intOpt("ai.search.embeddingCache.maxSize").getOrElse(500)
8380

8481
val maybeAgencyPickQuery: Option[Query] = agencyPicksIngredients.map { ingredients =>

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

Lines changed: 8 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -174,40 +174,20 @@ class ElasticSearch(
174174
}
175175
}
176176

177-
def knnSearch(queryEmbedding: List[Float], k: Int, offset: Int, length: Int, countAll: Boolean, numCandidates: Int)
177+
def knnSearch(queryEmbedding: List[Float], k: Int, numCandidates: Int)
178178
(implicit ex: ExecutionContext, logMarker: LogMarker): Future[SearchResults] = {
179-
if (length <= 0 || k <= 0) {
180-
Future.successful(SearchResults(Nil, total = 0, extraCounts = None))
181-
} else {
182-
val knn = Knn("embedding.cohereEmbedV4.image")
179+
val knn = Knn("embedding.cohereEmbedV4.image")
183180
.queryVector(queryEmbedding.map(_.toDouble))
184181
.k(k)
185182
.numCandidates(numCandidates)
186183

187-
val searchRequest = ElasticDsl.search(imagesCurrentAlias)
188-
.trackTotalHits(countAll)
189-
.knn(knn)
190-
.from(offset)
191-
.size(length)
184+
val searchRequest = ElasticDsl.search(imagesCurrentAlias)
185+
.knn(knn)
186+
.size(k)
192187

193-
executeAndLog(withSearchQueryTimeout(searchRequest), "knn search").map { r =>
194-
val imageHits = r.result.hits.hits.map(resolveHit).toSeq.flatten.map(i => (i.instance.id, i))
195-
// In semantic search we expose a capped/approximate total based on the
196-
// configured retrieval window (`k`) rather than an exact global corpus size.
197-
// This aligns pagination semantics with the bounded nearest-neighbour set.
198-
// Note: when trackTotalHits(false), ES may not populate totalHits.
199-
val total = if (countAll) {
200-
val totalHits = Option(r.result.totalHits).getOrElse(0L)
201-
Math.min(totalHits, k.toLong)
202-
} else {
203-
0L
204-
}
205-
SearchResults(
206-
hits = imageHits,
207-
total = total,
208-
extraCounts = None
209-
)
210-
}
188+
executeAndLog(withSearchQueryTimeout(searchRequest), "knn search").map { r =>
189+
val imageHits = r.result.hits.hits.map(resolveHit).toSeq.flatten.map(i => (i.instance.id, i))
190+
SearchResults(hits = imageHits, total = r.result.totalHits, extraCounts = None)
211191
}
212192
}
213193

0 commit comments

Comments
 (0)