mirror of https://github.com/immich-app/immich.git
feat(server, web): smart search filtering and pagination (#6525)
* initial pagination impl * use limit + offset instead of take + skip * wip web pagination * working infinite scroll * update api * formatting * fix rebase * search refactor * re-add runtime config for vector search * fix rebase * fixes * useless omitBy * unnecessary handling * add sql decorator for `searchAssets` * fixed search builder * fixed sql * remove mock method * linting * fixed pagination * fixed unit tests * formatting * fix e2e tests * re-flatten search builder * refactor endpoints * clean up dto * refinements * don't break everything just yet * update openapi spec & sql * update api * linting * update sql * fixes * optimize web code * fix typing * add page limit * make limit based on asset count * increase limit * simpler importpull/7071/head
parent
f1e4fdf175
commit
e334443919
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -1,29 +0,0 @@
|
||||
import { AssetEntity, AssetFaceEntity, SmartInfoEntity } from '@app/infra/entities';
|
||||
|
||||
export const ISmartInfoRepository = 'ISmartInfoRepository';
|
||||
|
||||
export type Embedding = number[];
|
||||
|
||||
export interface EmbeddingSearch {
|
||||
userIds: string[];
|
||||
embedding: Embedding;
|
||||
numResults: number;
|
||||
withArchived?: boolean;
|
||||
}
|
||||
|
||||
export interface FaceEmbeddingSearch extends EmbeddingSearch {
|
||||
maxDistance?: number;
|
||||
hasPerson?: boolean;
|
||||
}
|
||||
|
||||
export interface FaceSearchResult {
|
||||
face: AssetFaceEntity;
|
||||
distance: number;
|
||||
}
|
||||
|
||||
export interface ISmartInfoRepository {
|
||||
init(modelName: string): Promise<void>;
|
||||
searchCLIP(search: EmbeddingSearch): Promise<AssetEntity[]>;
|
||||
searchFaces(search: FaceEmbeddingSearch): Promise<FaceSearchResult[]>;
|
||||
upsert(smartInfo: Partial<SmartInfoEntity>, embedding?: Embedding): Promise<void>;
|
||||
}
|
||||
@ -0,0 +1,234 @@
|
||||
-- NOTE: This file is auto generated by ./sql-generator
|
||||
|
||||
-- SearchRepository.searchMetadata
|
||||
SELECT DISTINCT
|
||||
"distinctAlias"."asset_id" AS "ids_asset_id",
|
||||
"distinctAlias"."asset_fileCreatedAt"
|
||||
FROM
|
||||
(
|
||||
SELECT
|
||||
"asset"."id" AS "asset_id",
|
||||
"asset"."deviceAssetId" AS "asset_deviceAssetId",
|
||||
"asset"."ownerId" AS "asset_ownerId",
|
||||
"asset"."libraryId" AS "asset_libraryId",
|
||||
"asset"."deviceId" AS "asset_deviceId",
|
||||
"asset"."type" AS "asset_type",
|
||||
"asset"."originalPath" AS "asset_originalPath",
|
||||
"asset"."resizePath" AS "asset_resizePath",
|
||||
"asset"."webpPath" AS "asset_webpPath",
|
||||
"asset"."thumbhash" AS "asset_thumbhash",
|
||||
"asset"."encodedVideoPath" AS "asset_encodedVideoPath",
|
||||
"asset"."createdAt" AS "asset_createdAt",
|
||||
"asset"."updatedAt" AS "asset_updatedAt",
|
||||
"asset"."deletedAt" AS "asset_deletedAt",
|
||||
"asset"."fileCreatedAt" AS "asset_fileCreatedAt",
|
||||
"asset"."localDateTime" AS "asset_localDateTime",
|
||||
"asset"."fileModifiedAt" AS "asset_fileModifiedAt",
|
||||
"asset"."isFavorite" AS "asset_isFavorite",
|
||||
"asset"."isArchived" AS "asset_isArchived",
|
||||
"asset"."isExternal" AS "asset_isExternal",
|
||||
"asset"."isReadOnly" AS "asset_isReadOnly",
|
||||
"asset"."isOffline" AS "asset_isOffline",
|
||||
"asset"."checksum" AS "asset_checksum",
|
||||
"asset"."duration" AS "asset_duration",
|
||||
"asset"."isVisible" AS "asset_isVisible",
|
||||
"asset"."livePhotoVideoId" AS "asset_livePhotoVideoId",
|
||||
"asset"."originalFileName" AS "asset_originalFileName",
|
||||
"asset"."sidecarPath" AS "asset_sidecarPath",
|
||||
"asset"."stackId" AS "asset_stackId",
|
||||
"stack"."id" AS "stack_id",
|
||||
"stack"."primaryAssetId" AS "stack_primaryAssetId",
|
||||
"stackedAssets"."id" AS "stackedAssets_id",
|
||||
"stackedAssets"."deviceAssetId" AS "stackedAssets_deviceAssetId",
|
||||
"stackedAssets"."ownerId" AS "stackedAssets_ownerId",
|
||||
"stackedAssets"."libraryId" AS "stackedAssets_libraryId",
|
||||
"stackedAssets"."deviceId" AS "stackedAssets_deviceId",
|
||||
"stackedAssets"."type" AS "stackedAssets_type",
|
||||
"stackedAssets"."originalPath" AS "stackedAssets_originalPath",
|
||||
"stackedAssets"."resizePath" AS "stackedAssets_resizePath",
|
||||
"stackedAssets"."webpPath" AS "stackedAssets_webpPath",
|
||||
"stackedAssets"."thumbhash" AS "stackedAssets_thumbhash",
|
||||
"stackedAssets"."encodedVideoPath" AS "stackedAssets_encodedVideoPath",
|
||||
"stackedAssets"."createdAt" AS "stackedAssets_createdAt",
|
||||
"stackedAssets"."updatedAt" AS "stackedAssets_updatedAt",
|
||||
"stackedAssets"."deletedAt" AS "stackedAssets_deletedAt",
|
||||
"stackedAssets"."fileCreatedAt" AS "stackedAssets_fileCreatedAt",
|
||||
"stackedAssets"."localDateTime" AS "stackedAssets_localDateTime",
|
||||
"stackedAssets"."fileModifiedAt" AS "stackedAssets_fileModifiedAt",
|
||||
"stackedAssets"."isFavorite" AS "stackedAssets_isFavorite",
|
||||
"stackedAssets"."isArchived" AS "stackedAssets_isArchived",
|
||||
"stackedAssets"."isExternal" AS "stackedAssets_isExternal",
|
||||
"stackedAssets"."isReadOnly" AS "stackedAssets_isReadOnly",
|
||||
"stackedAssets"."isOffline" AS "stackedAssets_isOffline",
|
||||
"stackedAssets"."checksum" AS "stackedAssets_checksum",
|
||||
"stackedAssets"."duration" AS "stackedAssets_duration",
|
||||
"stackedAssets"."isVisible" AS "stackedAssets_isVisible",
|
||||
"stackedAssets"."livePhotoVideoId" AS "stackedAssets_livePhotoVideoId",
|
||||
"stackedAssets"."originalFileName" AS "stackedAssets_originalFileName",
|
||||
"stackedAssets"."sidecarPath" AS "stackedAssets_sidecarPath",
|
||||
"stackedAssets"."stackId" AS "stackedAssets_stackId"
|
||||
FROM
|
||||
"assets" "asset"
|
||||
LEFT JOIN "exif" "exifInfo" ON "exifInfo"."assetId" = "asset"."id"
|
||||
LEFT JOIN "asset_stack" "stack" ON "stack"."id" = "asset"."stackId"
|
||||
LEFT JOIN "assets" "stackedAssets" ON "stackedAssets"."stackId" = "stack"."id"
|
||||
AND ("stackedAssets"."deletedAt" IS NULL)
|
||||
WHERE
|
||||
(
|
||||
"asset"."fileCreatedAt" >= $1
|
||||
AND "exifInfo"."lensModel" = $2
|
||||
AND "asset"."ownerId" = $3
|
||||
AND 1 = 1
|
||||
AND "asset"."isFavorite" = $4
|
||||
AND (
|
||||
"stack"."primaryAssetId" = "asset"."id"
|
||||
OR "asset"."stackId" IS NULL
|
||||
)
|
||||
)
|
||||
AND ("asset"."deletedAt" IS NULL)
|
||||
) "distinctAlias"
|
||||
ORDER BY
|
||||
"distinctAlias"."asset_fileCreatedAt" DESC,
|
||||
"asset_id" ASC
|
||||
LIMIT
|
||||
101
|
||||
|
||||
-- SearchRepository.searchSmart
|
||||
START TRANSACTION
|
||||
SET
|
||||
LOCAL vectors.enable_prefilter = on;
|
||||
|
||||
SET
|
||||
LOCAL vectors.search_mode = vbase;
|
||||
|
||||
SET
|
||||
LOCAL vectors.hnsw_ef_search = 100;
|
||||
SELECT
|
||||
"asset"."id" AS "asset_id",
|
||||
"asset"."deviceAssetId" AS "asset_deviceAssetId",
|
||||
"asset"."ownerId" AS "asset_ownerId",
|
||||
"asset"."libraryId" AS "asset_libraryId",
|
||||
"asset"."deviceId" AS "asset_deviceId",
|
||||
"asset"."type" AS "asset_type",
|
||||
"asset"."originalPath" AS "asset_originalPath",
|
||||
"asset"."resizePath" AS "asset_resizePath",
|
||||
"asset"."webpPath" AS "asset_webpPath",
|
||||
"asset"."thumbhash" AS "asset_thumbhash",
|
||||
"asset"."encodedVideoPath" AS "asset_encodedVideoPath",
|
||||
"asset"."createdAt" AS "asset_createdAt",
|
||||
"asset"."updatedAt" AS "asset_updatedAt",
|
||||
"asset"."deletedAt" AS "asset_deletedAt",
|
||||
"asset"."fileCreatedAt" AS "asset_fileCreatedAt",
|
||||
"asset"."localDateTime" AS "asset_localDateTime",
|
||||
"asset"."fileModifiedAt" AS "asset_fileModifiedAt",
|
||||
"asset"."isFavorite" AS "asset_isFavorite",
|
||||
"asset"."isArchived" AS "asset_isArchived",
|
||||
"asset"."isExternal" AS "asset_isExternal",
|
||||
"asset"."isReadOnly" AS "asset_isReadOnly",
|
||||
"asset"."isOffline" AS "asset_isOffline",
|
||||
"asset"."checksum" AS "asset_checksum",
|
||||
"asset"."duration" AS "asset_duration",
|
||||
"asset"."isVisible" AS "asset_isVisible",
|
||||
"asset"."livePhotoVideoId" AS "asset_livePhotoVideoId",
|
||||
"asset"."originalFileName" AS "asset_originalFileName",
|
||||
"asset"."sidecarPath" AS "asset_sidecarPath",
|
||||
"asset"."stackId" AS "asset_stackId",
|
||||
"stack"."id" AS "stack_id",
|
||||
"stack"."primaryAssetId" AS "stack_primaryAssetId",
|
||||
"stackedAssets"."id" AS "stackedAssets_id",
|
||||
"stackedAssets"."deviceAssetId" AS "stackedAssets_deviceAssetId",
|
||||
"stackedAssets"."ownerId" AS "stackedAssets_ownerId",
|
||||
"stackedAssets"."libraryId" AS "stackedAssets_libraryId",
|
||||
"stackedAssets"."deviceId" AS "stackedAssets_deviceId",
|
||||
"stackedAssets"."type" AS "stackedAssets_type",
|
||||
"stackedAssets"."originalPath" AS "stackedAssets_originalPath",
|
||||
"stackedAssets"."resizePath" AS "stackedAssets_resizePath",
|
||||
"stackedAssets"."webpPath" AS "stackedAssets_webpPath",
|
||||
"stackedAssets"."thumbhash" AS "stackedAssets_thumbhash",
|
||||
"stackedAssets"."encodedVideoPath" AS "stackedAssets_encodedVideoPath",
|
||||
"stackedAssets"."createdAt" AS "stackedAssets_createdAt",
|
||||
"stackedAssets"."updatedAt" AS "stackedAssets_updatedAt",
|
||||
"stackedAssets"."deletedAt" AS "stackedAssets_deletedAt",
|
||||
"stackedAssets"."fileCreatedAt" AS "stackedAssets_fileCreatedAt",
|
||||
"stackedAssets"."localDateTime" AS "stackedAssets_localDateTime",
|
||||
"stackedAssets"."fileModifiedAt" AS "stackedAssets_fileModifiedAt",
|
||||
"stackedAssets"."isFavorite" AS "stackedAssets_isFavorite",
|
||||
"stackedAssets"."isArchived" AS "stackedAssets_isArchived",
|
||||
"stackedAssets"."isExternal" AS "stackedAssets_isExternal",
|
||||
"stackedAssets"."isReadOnly" AS "stackedAssets_isReadOnly",
|
||||
"stackedAssets"."isOffline" AS "stackedAssets_isOffline",
|
||||
"stackedAssets"."checksum" AS "stackedAssets_checksum",
|
||||
"stackedAssets"."duration" AS "stackedAssets_duration",
|
||||
"stackedAssets"."isVisible" AS "stackedAssets_isVisible",
|
||||
"stackedAssets"."livePhotoVideoId" AS "stackedAssets_livePhotoVideoId",
|
||||
"stackedAssets"."originalFileName" AS "stackedAssets_originalFileName",
|
||||
"stackedAssets"."sidecarPath" AS "stackedAssets_sidecarPath",
|
||||
"stackedAssets"."stackId" AS "stackedAssets_stackId"
|
||||
FROM
|
||||
"assets" "asset"
|
||||
LEFT JOIN "exif" "exifInfo" ON "exifInfo"."assetId" = "asset"."id"
|
||||
LEFT JOIN "asset_stack" "stack" ON "stack"."id" = "asset"."stackId"
|
||||
LEFT JOIN "assets" "stackedAssets" ON "stackedAssets"."stackId" = "stack"."id"
|
||||
AND ("stackedAssets"."deletedAt" IS NULL)
|
||||
INNER JOIN "smart_search" "search" ON "search"."assetId" = "asset"."id"
|
||||
WHERE
|
||||
(
|
||||
"asset"."fileCreatedAt" >= $1
|
||||
AND "exifInfo"."lensModel" = $2
|
||||
AND 1 = 1
|
||||
AND 1 = 1
|
||||
AND "asset"."isFavorite" = $3
|
||||
AND (
|
||||
"stack"."primaryAssetId" = "asset"."id"
|
||||
OR "asset"."stackId" IS NULL
|
||||
)
|
||||
AND "asset"."ownerId" IN ($4)
|
||||
)
|
||||
AND ("asset"."deletedAt" IS NULL)
|
||||
ORDER BY
|
||||
"search"."embedding" <= > $5 ASC
|
||||
LIMIT
|
||||
101
|
||||
COMMIT
|
||||
|
||||
-- SearchRepository.searchFaces
|
||||
START TRANSACTION
|
||||
SET
|
||||
LOCAL vectors.enable_prefilter = on;
|
||||
|
||||
SET
|
||||
LOCAL vectors.search_mode = vbase;
|
||||
|
||||
SET
|
||||
LOCAL vectors.hnsw_ef_search = 100;
|
||||
WITH
|
||||
"cte" AS (
|
||||
SELECT
|
||||
"faces"."id" AS "id",
|
||||
"faces"."assetId" AS "assetId",
|
||||
"faces"."personId" AS "personId",
|
||||
"faces"."imageWidth" AS "imageWidth",
|
||||
"faces"."imageHeight" AS "imageHeight",
|
||||
"faces"."boundingBoxX1" AS "boundingBoxX1",
|
||||
"faces"."boundingBoxY1" AS "boundingBoxY1",
|
||||
"faces"."boundingBoxX2" AS "boundingBoxX2",
|
||||
"faces"."boundingBoxY2" AS "boundingBoxY2",
|
||||
"faces"."embedding" <= > $1 AS "distance"
|
||||
FROM
|
||||
"asset_faces" "faces"
|
||||
INNER JOIN "assets" "asset" ON "asset"."id" = "faces"."assetId"
|
||||
AND ("asset"."deletedAt" IS NULL)
|
||||
WHERE
|
||||
"asset"."ownerId" IN ($2)
|
||||
ORDER BY
|
||||
"faces"."embedding" <= > $1 ASC
|
||||
LIMIT
|
||||
100
|
||||
)
|
||||
SELECT
|
||||
res.*
|
||||
FROM
|
||||
"cte" "res"
|
||||
WHERE
|
||||
res.distance <= $3
|
||||
COMMIT
|
||||
@ -1,129 +0,0 @@
|
||||
-- NOTE: This file is auto generated by ./sql-generator
|
||||
|
||||
-- SmartInfoRepository.searchCLIP
|
||||
START TRANSACTION
|
||||
SET
|
||||
LOCAL vectors.enable_prefilter = on;
|
||||
|
||||
SET
|
||||
LOCAL vectors.search_mode = vbase;
|
||||
|
||||
SET
|
||||
LOCAL vectors.hnsw_ef_search = 100;
|
||||
SELECT
|
||||
"a"."id" AS "a_id",
|
||||
"a"."deviceAssetId" AS "a_deviceAssetId",
|
||||
"a"."ownerId" AS "a_ownerId",
|
||||
"a"."libraryId" AS "a_libraryId",
|
||||
"a"."deviceId" AS "a_deviceId",
|
||||
"a"."type" AS "a_type",
|
||||
"a"."originalPath" AS "a_originalPath",
|
||||
"a"."resizePath" AS "a_resizePath",
|
||||
"a"."webpPath" AS "a_webpPath",
|
||||
"a"."thumbhash" AS "a_thumbhash",
|
||||
"a"."encodedVideoPath" AS "a_encodedVideoPath",
|
||||
"a"."createdAt" AS "a_createdAt",
|
||||
"a"."updatedAt" AS "a_updatedAt",
|
||||
"a"."deletedAt" AS "a_deletedAt",
|
||||
"a"."fileCreatedAt" AS "a_fileCreatedAt",
|
||||
"a"."localDateTime" AS "a_localDateTime",
|
||||
"a"."fileModifiedAt" AS "a_fileModifiedAt",
|
||||
"a"."isFavorite" AS "a_isFavorite",
|
||||
"a"."isArchived" AS "a_isArchived",
|
||||
"a"."isExternal" AS "a_isExternal",
|
||||
"a"."isReadOnly" AS "a_isReadOnly",
|
||||
"a"."isOffline" AS "a_isOffline",
|
||||
"a"."checksum" AS "a_checksum",
|
||||
"a"."duration" AS "a_duration",
|
||||
"a"."isVisible" AS "a_isVisible",
|
||||
"a"."livePhotoVideoId" AS "a_livePhotoVideoId",
|
||||
"a"."originalFileName" AS "a_originalFileName",
|
||||
"a"."sidecarPath" AS "a_sidecarPath",
|
||||
"a"."stackId" AS "a_stackId",
|
||||
"e"."assetId" AS "e_assetId",
|
||||
"e"."description" AS "e_description",
|
||||
"e"."exifImageWidth" AS "e_exifImageWidth",
|
||||
"e"."exifImageHeight" AS "e_exifImageHeight",
|
||||
"e"."fileSizeInByte" AS "e_fileSizeInByte",
|
||||
"e"."orientation" AS "e_orientation",
|
||||
"e"."dateTimeOriginal" AS "e_dateTimeOriginal",
|
||||
"e"."modifyDate" AS "e_modifyDate",
|
||||
"e"."timeZone" AS "e_timeZone",
|
||||
"e"."latitude" AS "e_latitude",
|
||||
"e"."longitude" AS "e_longitude",
|
||||
"e"."projectionType" AS "e_projectionType",
|
||||
"e"."city" AS "e_city",
|
||||
"e"."livePhotoCID" AS "e_livePhotoCID",
|
||||
"e"."autoStackId" AS "e_autoStackId",
|
||||
"e"."state" AS "e_state",
|
||||
"e"."country" AS "e_country",
|
||||
"e"."make" AS "e_make",
|
||||
"e"."model" AS "e_model",
|
||||
"e"."lensModel" AS "e_lensModel",
|
||||
"e"."fNumber" AS "e_fNumber",
|
||||
"e"."focalLength" AS "e_focalLength",
|
||||
"e"."iso" AS "e_iso",
|
||||
"e"."exposureTime" AS "e_exposureTime",
|
||||
"e"."profileDescription" AS "e_profileDescription",
|
||||
"e"."colorspace" AS "e_colorspace",
|
||||
"e"."bitsPerSample" AS "e_bitsPerSample",
|
||||
"e"."fps" AS "e_fps"
|
||||
FROM
|
||||
"assets" "a"
|
||||
INNER JOIN "smart_search" "s" ON "s"."assetId" = "a"."id"
|
||||
LEFT JOIN "exif" "e" ON "e"."assetId" = "a"."id"
|
||||
WHERE
|
||||
(
|
||||
"a"."ownerId" IN ($1)
|
||||
AND "a"."isArchived" = false
|
||||
AND "a"."isVisible" = true
|
||||
AND "a"."fileCreatedAt" < NOW()
|
||||
)
|
||||
AND ("a"."deletedAt" IS NULL)
|
||||
ORDER BY
|
||||
"s"."embedding" <= > $2 ASC
|
||||
LIMIT
|
||||
100
|
||||
COMMIT
|
||||
|
||||
-- SmartInfoRepository.searchFaces
|
||||
START TRANSACTION
|
||||
SET
|
||||
LOCAL vectors.enable_prefilter = on;
|
||||
|
||||
SET
|
||||
LOCAL vectors.search_mode = vbase;
|
||||
|
||||
SET
|
||||
LOCAL vectors.hnsw_ef_search = 100;
|
||||
WITH
|
||||
"cte" AS (
|
||||
SELECT
|
||||
"faces"."id" AS "id",
|
||||
"faces"."assetId" AS "assetId",
|
||||
"faces"."personId" AS "personId",
|
||||
"faces"."imageWidth" AS "imageWidth",
|
||||
"faces"."imageHeight" AS "imageHeight",
|
||||
"faces"."boundingBoxX1" AS "boundingBoxX1",
|
||||
"faces"."boundingBoxY1" AS "boundingBoxY1",
|
||||
"faces"."boundingBoxX2" AS "boundingBoxX2",
|
||||
"faces"."boundingBoxY2" AS "boundingBoxY2",
|
||||
"faces"."embedding" <= > $1 AS "distance"
|
||||
FROM
|
||||
"asset_faces" "faces"
|
||||
INNER JOIN "assets" "asset" ON "asset"."id" = "faces"."assetId"
|
||||
AND ("asset"."deletedAt" IS NULL)
|
||||
WHERE
|
||||
"asset"."ownerId" IN ($2)
|
||||
ORDER BY
|
||||
"faces"."embedding" <= > $1 ASC
|
||||
LIMIT
|
||||
100
|
||||
)
|
||||
SELECT
|
||||
res.*
|
||||
FROM
|
||||
"cte" "res"
|
||||
WHERE
|
||||
res.distance <= $3
|
||||
COMMIT
|
||||
@ -0,0 +1,11 @@
|
||||
import { ISearchRepository } from '@app/domain';
|
||||
|
||||
export const newSearchRepositoryMock = (): jest.Mocked<ISearchRepository> => {
|
||||
return {
|
||||
init: jest.fn(),
|
||||
searchMetadata: jest.fn(),
|
||||
searchSmart: jest.fn(),
|
||||
searchFaces: jest.fn(),
|
||||
upsert: jest.fn(),
|
||||
};
|
||||
};
|
||||
@ -1,10 +0,0 @@
|
||||
import { ISmartInfoRepository } from '@app/domain';
|
||||
|
||||
export const newSmartInfoRepositoryMock = (): jest.Mocked<ISmartInfoRepository> => {
|
||||
return {
|
||||
init: jest.fn(),
|
||||
searchCLIP: jest.fn(),
|
||||
searchFaces: jest.fn(),
|
||||
upsert: jest.fn(),
|
||||
};
|
||||
};
|
||||
Loading…
Reference in New Issue