src/lib/typesense.ts
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 };
/**
* 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 };