src/lib/typesense.ts

function·app·4.6 KB · 150 lignes· Voir l'itinéraire
Annotation non disponible

Lance npm run annotate (nécessite ANTHROPIC_API_KEY dans .env.local) pour générer une annotation française par Claude Haiku 4.5.

8 exports

ensureProduitCollectionupsertProduitdeleteProduitsearchByEmbeddingProduitTypesenseEMBEDDING_VERSIONEMBED_DIMCOLLECTION_NAME

Code source· typescript

/**
 * V2 Search par image — Typesense client (option A : Typesense + HF CLIP).
 *
 * Service Typesense self-hosted sur VPS Paris (latence ~30ms p50 Afrique de l'Ouest).
 * Indexation des produits via worker batch (cron embed-produits) qui combine :
 *   1. HF CLIP embedding (lib/embedding.ts) → vector 512d
 *   2. Upsert dans collection 'produits' de Typesense
 *
 * Search image client :
 *   1. POST image → embedImage() → vector 512d
 *   2. Typesense vector search top-N similaires
 *   3. Return produit IDs + score similarité
 */

const TYPESENSE_HOST = process.env.TYPESENSE_HOST ?? "http://typesense:8108";
const TYPESENSE_API_KEY = process.env.TYPESENSE_API_KEY;

if (!TYPESENSE_API_KEY) {
  console.warn("[typesense] TYPESENSE_API_KEY non configuré");
}

const COLLECTION_NAME = "produits";
const EMBED_DIM = 512;
const EMBEDDING_VERSION = "clip-vit-b-32-v1";

type TypesenseSearchResult = {
  found: number;
  hits: Array<{
    document: {
      id: string;
      tenantId: string;
      nom: string;
      prix: number;
      devise: string;
      mediaUrl: string;
      embedding: number[];
    };
    vector_distance?: number; // cosine distance, plus petit = plus similaire
  }>;
  search_time_ms: number;
};

async function typesenseFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
  if (!TYPESENSE_API_KEY) throw new Error("TYPESENSE_API_KEY non configuré");
  const res = await fetch(`${TYPESENSE_HOST}${path}`, {
    ...init,
    headers: {
      "X-TYPESENSE-API-KEY": TYPESENSE_API_KEY,
      "Content-Type": "application/json",
      ...(init.headers ?? {}),
    },
  });
  if (!res.ok) {
    const txt = await res.text().catch(() => "");
    throw new Error(`Typesense ${res.status}: ${txt.slice(0, 200)}`);
  }
  return res.json() as Promise<T>;
}

/** Crée la collection 'produits' si elle n'existe pas. Idempotent. */
export async function ensureProduitCollection(): Promise<void> {
  try {
    await typesenseFetch(`/collections/${COLLECTION_NAME}`);
    return; // existe déjà
  } catch {
    // 404 → on crée
  }
  await typesenseFetch(`/collections`, {
    method: "POST",
    body: JSON.stringify({
      name: COLLECTION_NAME,
      fields: [
        { name: "id", type: "string" },
        { name: "tenantId", type: "string", facet: true },
        { name: "nom", type: "string" },
        { name: "description", type: "string", optional: true },
        { name: "prix", type: "float" },
        { name: "devise", type: "string" },
        { name: "mediaUrl", type: "string" },
        { name: "categorieId", type: "string", facet: true, optional: true },
        { name: "tags", type: "string[]", facet: true, optional: true },
        {
          name: "embedding",
          type: "float[]",
          num_dim: EMBED_DIM,
          vec_dist: "cosine",
        },
        { name: "embeddingVersion", type: "string" },
        { name: "updatedAt", type: "int64" },
      ],
      default_sorting_field: "updatedAt",
    }),
  });
}

export type ProduitTypesense = {
  id: string;
  tenantId: string;
  nom: string;
  description?: string;
  prix: number;
  devise: string;
  mediaUrl: string;
  categorieId?: string;
  tags?: string[];
  embedding: number[];
  embeddingVersion: string;
  updatedAt: number;
};

/** Upsert un produit avec son embedding dans Typesense. */
export async function upsertProduit(p: ProduitTypesense): Promise<void> {
  await typesenseFetch(`/collections/${COLLECTION_NAME}/documents?action=upsert`, {
    method: "POST",
    body: JSON.stringify(p),
  });
}

/** Supprime un produit (sync avec Prisma DELETE). */
export async function deleteProduit(id: string): Promise<void> {
  await typesenseFetch(`/collections/${COLLECTION_NAME}/documents/${encodeURIComponent(id)}`, {
    method: "DELETE",
  }).catch(() => undefined); // 404 = déjà absent, OK
}

/** Vector search top-N produits similaires à l'embedding query. */
export async function searchByEmbedding(
  embedding: number[],
  options: { limit?: number; tenantId?: string } = {},
): Promise<TypesenseSearchResult> {
  const limit = Math.min(Math.max(1, options.limit ?? 20), 100);
  const filter = options.tenantId ? `tenantId:=${options.tenantId}` : "";
  return typesenseFetch<{ results: TypesenseSearchResult[] }>(`/multi_search`, {
    method: "POST",
    body: JSON.stringify({
      searches: [
        {
          collection: COLLECTION_NAME,
          q: "*",
          vector_query: `embedding:([${embedding.join(",")}], k:${limit})`,
          filter_by: filter || undefined,
          per_page: limit,
        },
      ],
    }),
  }).then((r) => r.results?.[0] ?? { found: 0, hits: [], search_time_ms: 0 });
}

export { EMBEDDING_VERSION, EMBED_DIM, COLLECTION_NAME };