immich-app/immich · error · Error

Invalid value for 'numResults': ${numResults}

Error message

Invalid value for 'numResults': ${numResults}

What it means

This is a hand-rolled runtime guard inside Immich's SearchRepository.searchFaces, the vchordrq (pgvector) face-embedding similarity query. Before the SQL transaction runs, numResults (the LIMIT for the nearest-neighbor search) is validated with zod as z.int().min(1).max(1000); any non-integer, 0, negative, NaN, undefined, or value above 1000 throws this plain Error, aborting the facial-recognition job. The guard exists because numResults is passed straight into .limit() on an expensive vector index scan, so out-of-range values would produce degenerate or runaway queries.

Source

Thrown at server/src/repositories/search.repository.ts:355

    params: [DummyValue.UUID],
  })
  async getEmbedding(assetId: string) {
    return this.db.selectFrom('smart_search').selectAll().where('assetId', '=', assetId).executeTakeFirst();
  }

  @GenerateSql({
    params: [
      {
        userIds: [DummyValue.UUID],
        embedding: DummyValue.VECTOR,
        numResults: 10,
        maxDistance: 0.6,
      },
    ],
  })
  searchFaces({ clusterGroupId, embedding, numResults, maxDistance, hasPerson, minBirthDate }: FaceEmbeddingSearch) {
    if (!z.int().min(1).max(1000).safeParse(numResults).success) {
      throw new Error(`Invalid value for 'numResults': ${numResults}`);
    }

    return this.db.transaction().execute(async (trx) => {
      await sql`set local vchordrq.probes = ${sql.lit(probes[VectorIndex.Face])}`.execute(trx);
      return await trx
        .with('cte', (qb) =>
          qb
            .selectFrom('asset_face')
            .innerJoin('asset', 'asset.id', 'asset_face.assetId')
            .innerJoin('face_search', 'face_search.faceId', 'asset_face.id')
            .select([
              'asset_face.id',
              'asset_face.personGroupId',
              sql<number>`face_search.embedding <=> ${embedding}`.as('distance'),
            ])
            .where('asset.ownerId', 'in', (eb) =>
              eb.selectFrom('user').select('user.id').where('user.clusterGroupId', '=', clusterGroupId),
            )

View on GitHub (pinned to 5666d57f15)

Solutions

  1. Set Machine Learning > Facial Recognition > Minimum faces back to an integer between 1 and 1000 in Administration > Settings, then re-run the Facial Recognition job — this fixes the common config-driven case.
  2. If you call searchFaces directly, pass an integer clamped to 1..1000: Math.min(1000, Math.max(1, Math.trunc(numResults))).
  3. Harden the config DTO so invalid values are rejected at save time: change minFaces in src/dtos/config.dto.ts to z.int().min(1).max(1000) to match the repository contract.
  4. Check the facial-recognition queue logs to confirm which value was actually received — the message interpolates the offending numResults value.

Example fix

// before (src/services/person.service.ts / admin config)
// admin sets facialRecognition.minFaces = 5000; DTO allows it (no max)
numResults: machineLearning.facialRecognition.minFaces, // throws: Invalid value for 'numResults': 5000

// after — enforce the repository contract at the config boundary (src/dtos/config.dto.ts)
minFaces: z
  .int()
  .min(1)
  .max(1000) // match searchFaces' z.int().min(1).max(1000)
  .describe('Minimum number of faces required for recognition')
Defensive patterns

Strategy: validation

Validate before calling

// Before calling searchFaces (or queueing facial recognition with a custom minFaces):
import { z } from 'zod';

const NumResults = z.int().min(1).max(1000);
const parsed = NumResults.safeParse(numResults);
if (!parsed.success) {
  // clamp instead of aborting a long-running recognition job
  numResults = Math.min(1000, Math.max(1, Math.trunc(Number(numResults) || 1)));
}

Type guard

import { z } from 'zod';

const isValidNumResults = (value: unknown): value is number =>
  z.int().min(1).max(1000).safeParse(value).success;

Try / catch

// Inside a job handler / wrapper around searchFaces:
try {
  return await this.searchRepository.searchFaces({ ... });
} catch (error) {
  if (error instanceof Error && error.message.startsWith("Invalid value for 'numResults'")) {
    this.logger.warn(`Skipping face ${id}: ${error.message} (check facialRecognition.minFaces config)`);
    return JobStatus.Failed; // do not retry an unchanged config value
  }
  throw error;
}

Prevention

When it happens

Trigger: The main production caller is PersonService.handleRecognizeFaces (server/src/services/person.service.ts:501), which passes machineLearning.facialRecognition.minFaces directly as numResults. The config DTO only enforces z.int().min(1) with NO upper bound (server/src/dtos/config.dto.ts:243-247), so setting Facial Recognition > Minimum faces to a value above 1000 in the admin UI makes every FacialRecognition job queue item throw this error. It is also thrown when any direct caller of searchFaces passes 0, a float like 10.5, a numeric string, NaN, or an unbounded computed value (e.g. array.length when the array is empty).

Common situations: Admins raising the 'minimum faces for recognition' threshold very high to suppress noisy person clusters, then facial recognition silently failing for all jobs. Custom forks or scripts calling searchRepository.searchFaces with a dynamically derived count. Type drift after refactors where numResults becomes a string or optional field (undefined fails the zod parse).

Related errors


AI-assisted analysis of immich-app/immich@5666d57f15 (2026-08-21). Data as JSON: /api/errors/8bb4e3838a846e70. Report an issue: GitHub.