Skip to content
中文

Vector validation, normalization, filtering, and grouped ordering ​

The policy implementation follows.

ts
function normalize(vector: readonly number[], dimensions: number): Float32Array {
  breakWhile(
    !Array.isArray(vector) ||
      vector.length !== dimensions ||
      Array.from(vector).some((n) => typeof n !== 'number' || !Number.isFinite(n)),
    'Invalid vector dimensions or values',
  );
  const scale = Math.max(...vector.map(Math.abs));
  breakWhile(scale === 0, 'Zero vector cannot be searched');
  const norm = Math.sqrt(vector.reduce((sum, n) => sum + (n / scale) ** 2, 0));
  return Float32Array.from(vector, (n) => n / scale / norm);
}

export class VectorSearch<Document extends { id: string }> {
  private readonly entries = new Map<string, { document: Document; vector: Float32Array }>();
  /** The declared space; compare it before mixing vectors from another source. */
  readonly space: VectorSearchSpace;
  /**
   * @param space - Non-blank identity and dimensions (1..16384) of the corpus.
   * @throws TypeError When the space declaration is invalid.
   */
  constructor(space: VectorSearchSpace) {
    breakWhile(
      !space.identity?.trim() ||
        !Number.isSafeInteger(space.dimensions) ||
        space.dimensions < 1 ||
        space.dimensions > 16384,
      'Invalid vector space',
    );
    this.space = Object.freeze({ identity: space.identity, dimensions: space.dimensions });
  }
  /**
   * Insert or replace one document and its embedding.
   *
   * @param document - Document with a non-blank string `id`; it is cloned before being stored.
   * @param vector - Raw vector for this space.
   * @throws TypeError For a blank/missing id, a vector of the wrong width, a non-finite
   *   component, or an all-zero vector (cosine is undefined for it).
   */
  upsert({ document, vector }: { document: Document; vector: readonly number[] }): void {
    breakWhile(!document?.id?.trim(), 'Invalid vector document id');
    this.entries.set(document.id, {
      document: structuredClone(document),
      vector: normalize(vector, this.space.dimensions),
    });
  }
  /** Remove one document; returns whether it was present. */
  remove(id: string) {
    return this.entries.delete(id);
  }
  /** Drop the whole corpus while keeping the declared space. */
  clear() {
    this.entries.clear();
  }
  /**
   * Score every candidate and return the best hits.
   *
   * @param options - Query vector plus limit, filter, minimum score and optional grouping.
   * @returns At most `limit` hits, best score first, ties broken by id.
   * @throws TypeError For an invalid limit, a missing filter, an out-of-range `minScore`, a
   *   vector the space rejects, a non-synchronous filter or grouper, or a non-string group.
   */
  search(
    options: VectorSearchQuery<Document> & { vector: readonly number[] },
  ): VectorSearchHit<Document>[] {
    const { vector } = options;
    breakWhile(
      !Number.isSafeInteger(options.limit) ||
        options.limit < 1 ||
        options.limit > 1000 ||
        typeof options.filter !== 'function' ||
        (options.minScore !== undefined &&
          (!Number.isFinite(options.minScore) || options.minScore < -1 || options.minScore > 1)),
      'Invalid vector query',
    );
    const query = normalize(vector, this.space.dimensions),
      groups = new Map<string, VectorSearchHit<Document>>();
    for (const [id, { document, vector: value }] of this.entries) {
      // Each callback receives its own snapshot; readonly types cannot prevent runtime mutation.
      const allowed = booleanResult(
        options.filter(structuredClone(document)),
        'Vector filters must be synchronous',
        'Vector filters must return boolean',
      );
      if (!allowed) continue;
      let score = 0;
      for (let i = 0; i < query.length; i++) score += query[i] * value[i];
      score = Math.max(-1, Math.min(1, score));
      if (score < (options.minScore ?? -1)) continue;
      const group =
        synchronous(
          options.groupBy?.(structuredClone(document)),
          'Vector grouping must be synchronous',
        ) ?? id;
      if (typeof group !== 'string') throw new TypeError('Vector groups must be strings');
      const prior = groups.get(group);
      if (!prior || score > prior.score || (score === prior.score && id < prior.id))
        groups.set(group, { id, score, document: structuredClone(document) });
    }
    return [...groups.values()]
      .sort((a, b) => b.score - a.score || a.id.localeCompare(b.id))
      .slice(0, options.limit);
  }
}

API reference, usage examples and developer guides.