# Story: Embeddings par Fragments — Indexation Sémantique Multi-Niveaux > **Epic:** Fondation IA — Recherche & Insights Sémantiques > **ID:** US-CHUNK-EMBEDDINGS > **Priority:** Critical — Fondation pour Workstreams B (AI Overview), Memory Echo précis, Insights > **Status:** ready-for-dev > **Depends on:** pgvector existant (✅), `embedding.service.ts` (✅), `NoteEmbedding` (✅) > **Blocks:** US-AI-OVERVIEW, US-CALENDAR (indépendant), qualité Memory Echo et Insights > **Inspiration:** AppFlowy `flowy-ai/src/embeddings/` — chunking + dedup par hash de contenu --- ## Contexte ### Problème actuel Memento stocke **un seul vecteur par note** (`NoteEmbedding` dans `prisma/schema.prisma:366-374`). L'embedding est généré par `EmbeddingService.generateNoteEmbedding()` (`lib/ai/services/embedding.service.ts:43-63`) qui : 1. Concatène titre + corps en plain text 2. Découpe en chunks de 6000 chars (`splitPlainTextForEmbeddingChunks`) 3. Embed chaque chunk via l'API 4. **Mean-pool** les vecteurs en un seul vecteur moyen Cette approche a trois limites majeures : | Limite | Impact | |--------|--------| | **Perte de précision** — une note de 5000 mots sur 10 sujets différents a un vecteur moyen flou | Memory Echo rate les connexions spécifiques à une section | | **Re-embed complet à chaque modif** — corriger une typo re-embedde toute la note | Coût API × nombre d'éditions ; latence inutile | | **Pas de snippets de match** — la recherche retourne un score global mais pas le passage précis | L'utilisateur ne sait pas *pourquoi* une note match | ### Solution proposée Inspiré d'AppFlowy (`flowy-ai/src/embeddings/document_indexer.rs`, `scheduler.rs`) : 1. **Découper chaque note en fragments sémantiques** (~1000 chars, coupure aux limites de paragraphes) 2. **Embedder chaque fragment indépendamment** dans une nouvelle table `NoteEmbeddingChunk` 3. **Hasher le contenu de chaque fragment** (`sha256`) pour ne re-embed que les fragments modifiés 4. **Rechercher au niveau fragment** avec agrégation par note → snippets précis en résultat 5. **Garder `NoteEmbedding` existant** pour rétro-compat (recherche globale, clustering) ### Comparaison AppFlowy → Memento | Aspect | AppFlowy (Rust) | Memento (TypeScript) | |--------|-----------------|---------------------| | Chunking | `text_splitter` crate, 1000 chars / 200 overlap | `lib/text/note-chunking.ts`, même valeurs | | Hash | `xxhash64` (Rust) | `crypto.createHash('sha256')` (Node natif) | | Vector DB | SQLite-vec (local) | pgvector (PostgreSQL, déjà en prod) | | Scheduler | Tokio channels (generate → write) | `p-queue` ou Bull (Redis déjà présent) | | Dedup | Compare `fragment_id` en DB | Identique — `@@unique([noteId, fragmentId])` | | Embedding model | `nomic-embed-text` (Ollama local) | `text-embedding-3-small` (OpenAI, déjà configuré) | --- ## Migration Prisma requise ```prisma model NoteEmbeddingChunk { id String @id @default(cuid()) noteId String fragmentId String // sha256(noteId + content) — stable pour le dedup chunkIndex Int // ordre du fragment dans la note content String // texte plain du fragment (~1000 chars) charCount Int // longueur du fragment (audit / debug) embedding Unsupported("vector(1536)")? embeddingModel String? @default("text-embedding-3-small") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt note Note @relation(fields: [noteId], references: [id], onDelete: Cascade) @@unique([noteId, fragmentId]) @@index([noteId]) @@index([fragmentId]) } ``` > ⚠️ **Migration additive uniquement** — safe, pas de perte de données. La table `NoteEmbedding` existante est conservée intacte. ### Étapes de migration 1. **Dump DB obligatoire** : `bash /home/devparsa/dev/Memento/dump-db.sh` — vérifier ≥1Mo — « OUI » explicite utilisateur 2. `npx prisma migrate dev --name add_note_embedding_chunks` 3. Créer l'index HNSW sur la colonne `embedding` : ```sql CREATE INDEX "NoteEmbeddingChunk_embedding_hnsw_idx" ON "NoteEmbeddingChunk" USING hnsw ("embedding" vector_cosine_ops) WITH (m = 16, ef_construction = 64); ``` > L'index HNSW doit être créé manuellement (Prisma ne gère pas les index vectoriels). À mettre dans la migration SQL brute. --- ## User Stories ### US-CHUNK-1 : Chunking sémantique d'une note **En tant que** système d'indexation, **Je veux** découper le contenu d'une note en fragments sémantiques cohérents, **Afin de** produire des embeddings précis au niveau section plutôt qu'au niveau note entière. #### Critères d'acceptation : - **Étant donné** une note avec un titre et un contenu HTML/plain - **Quand** le service de chunking est appelé - **Alors** le contenu est converti en plain text (via `stripHtmlToPlainText` existant) - **Et** découpé aux limites de paragraphes (`\n\n`) en groupes de ~1000 caractères max - **Et** chaque fragment chevauche le précédent de ~200 caractères (préserve le contexte aux frontières) - **Et** chaque fragment reçoit un `fragmentId` = `sha256(noteId + "::" + fragmentContent)` (stable, déterministe) - **Et** si un paragraphe dépasse 1000 chars, il est sous-découpé à la phrase la plus proche (regex sur `. ! ? ؟ !。`) - **Et** si le contenu total fait moins de 1000 chars, un seul fragment est produit - **Et** les fragments vides (< 10 chars après trim) sont filtrés #### Paramètres de chunking : ```typescript const CHUNK_TARGET_CHARS = 1000 // taille cible par fragment const CHUNK_OVERLAP_CHARS = 200 // chevauchement entre fragments consécutifs const MIN_FRAGMENT_CHARS = 10 // en dessous, le fragment est ignoré const MAX_PARAGRAPH_SPLIT = 1500 // au-dessus, un paragraphe est sous-découpé ``` > Ces valeurs sont alignées sur AppFlowy (`document_indexer.rs:31`) et la recherche RAG standard (medium chunks 256-1024 tokens). --- ### US-CHUNK-2 : Indexation incrémentale avec dedup **En tant que** système, **Je veux** ne re-embed que les fragments qui ont réellement changé, **Afin de** réduire le coût API et la latence lors des éditions mineures. #### Critères d'acceptation : - **Étant donné** une note qui a déjà des fragments indexés en DB - **Quand** l'utilisateur modifie un paragraphe et sauvegarde - **Alors** le service : 1. Récupère les `fragmentId`s existants : `SELECT fragmentId FROM "NoteEmbeddingChunk" WHERE noteId = $1` 2. Chunk le contenu actuel → produit de nouveaux fragments avec leurs `fragmentId`s 3. Compare les hash : - **Fragments inchangés** (hash identique) → **skip**, pas d'embed - **Fragments nouveaux** (hash absent en DB) → **embed et insère** - **Fragments supprimés** (hash en DB mais absent des nouveaux) → **DELETE de la DB** 4. Embed uniquement les fragments nouveaux (batch de max 100 fragments par appel API) 5. Insère en DB via upsert transactionnel - **Et** si l'utilisateur corrige une typo dans un paragraphe, seul ce fragment est re-embeddé (pas toute la note) - **Et** si l'utilisateur ajoute un nouveau paragraphe à la fin, seul le nouveau fragment est embeddé - **Et** les fragments supprimés sont nettoyés (pas d'orphelins) #### Gestion de la concurrence : - Utiliser un **verrou par noteId** (mutex en mémoire ou lock Redis `lock:chunk-index:{noteId}`) - Si une indexation est déjà en cours pour cette note, la nouvelle requête est mise en file d'attente (ou débounce 3s puis prend le relais) - Pas de race condition sur les upserts (`@@unique([noteId, fragmentId])` + `ON CONFLICT DO UPDATE`) --- ### US-CHUNK-3 : Pipeline d'indexation asynchrone **En tant que** système, **Je veux** que l'indexation des fragments se fasse en arrière-plan sans bloquer la sauvegarde, **Afin de** préserver la fluidité de l'édition. #### Critères d'acceptation : - **Quand** une note est sauvegardée (débounce existant dans le flow de save) - **Alors** l'indexation est déclenchée **de manière asynchrone** (fire-and-forget côté serveur) - **Et** l'utilisateur ne perçoit aucune latence liée à l'embedding - **Et** l'indexation utilise une **queue avec concurrence limitée** (max 4 embeddings simultanés) pour éviter le rate-limit API - **Et** en cas d'erreur API (rate limit, timeout), le fragment est **réessayé** avec backoff exponentiel (3 tentatives max) - **Et** en cas d'échec définitif, l'erreur est loggée mais ne bloque pas l'application #### Implémentation de la queue : Utiliser `p-queue` (léger, pas de dépendance Redis supplémentaire) ou Bull (Redis déjà présent à `memento-redis:6379`). Préférer **`p-queue`** pour simplicité — un singleton process-level : ```typescript import PQueue from 'p-queue' const chunkEmbeddingQueue = new PQueue({ concurrency: 4 }) ``` > Si Memento passe multi-process (PM2 cluster), migrer vers Bull. Pour l'instant, single-process Next.js → `p-queue` suffit. --- ### US-CHUNK-4 : Recherche sémantique au niveau fragment **En tant qu'** utilisateur, **Je veux** que la recherche sémantique trouve des passages précis dans mes notes, **Afin de** comprendre pourquoi une note correspond à ma recherche. #### Critères d'acceptation : - **Étant donné** que l'utilisateur saisit une requête dans la SearchModal - **Quand** la recherche sémantique s'exécute - **Alors** le système effectue **deux recherches en parallèle** : 1. **Recherche fragment-level** (nouvelle) : `pgvector` sur `NoteEmbeddingChunk.embedding` 2. **Recherche note-level** (existante) : `pgvector` sur `NoteEmbedding.embedding` + FTS `tsvector` - **Et** les résultats sont fusionnés via RRF (Reciprocal Rank Fusion, déjà implémenté dans `semantic-search.service.ts`) - **Et** chaque résultat inclut désormais un champ `matchedSnippets: string[]` (top 3 fragments par score, max 200 chars chacun, avec `...` aux coupures) - **Et** les snippets sont affichés dans l'UI de recherche sous le titre de la note (mise en gras du terme si présent) #### Requête SQL fragment-level : ```sql -- Recherche vectorielle au niveau fragment, agrégée par note WITH chunk_scores AS ( SELECT c."noteId", MAX(1 - (c."embedding"::vector <=> $1::vector)) AS best_score, ARRAY_AGG(c.content ORDER BY 1 - (c."embedding"::vector <=> $1::vector) DESC) AS ranked_contents FROM "NoteEmbeddingChunk" c JOIN "Note" n ON n.id = c."noteId" WHERE n."userId" = $2 AND c."embedding" IS NOT NULL AND 1 - (c."embedding"::vector <=> $1::vector) >= $3 -- threshold GROUP BY c."noteId" LIMIT $4 ) SELECT cs."noteId", cs.best_score, cs.ranked_contents[1:3] AS top_snippets -- top 3 fragments FROM chunk_scores cs ORDER BY cs.best_score DESC ``` > Le `[1:3]` est du slicing PostgreSQL natif sur les arrays. Chaque snippet est tronqué à 200 chars côté application. --- ### US-CHUNK-5 : Memory Echo précis au niveau fragment **En tant qu'** utilisateur, **Je veux** que Memory Echo détecte des connexions entre sections spécifiques de notes, **Afin de** découvrir des résonances même entre notes majoritairement différentes. #### Critères d'acceptation : - **Étant donné** deux notes A et B indexées en fragments - **Quand** Memory Echo calcule la similarité entre A et B - **Alors** au lieu de comparer `embedding(A)` vs `embedding(B)` (note entière) - **Le système compare** la **meilleure paire de fragments** : `MAX(similarity(fragmentAi, fragmentBj))` sur tous les cross-joins - **Et** si la meilleure paire dépasse le seuil (`SEMANTIC_SIMILARITY_FLOOR`), une connexion est créée - **Et** l'insight Memory Echo affiche le **snippet précis** qui a résonné (pas juste le score global) - **Et** le filtre temporel (`MIN_DAYS_APART`) reste identique #### Requête SQL (cross-join fragment) : ```sql SELECT a."noteId" AS note1_id, b."noteId" AS note2_id, MAX(1 - (a."embedding"::vector <=> b."embedding"::vector)) AS best_fragment_similarity, -- Récupérer le contenu des fragments qui matchent le mieux ( SELECT a2.content FROM "NoteEmbeddingChunk" a2 WHERE a2."noteId" = a."noteId" AND 1 - (a2."embedding"::vector <=> b."embedding"::vector) = MAX(1 - (a."embedding"::vector <=> b."embedding"::vector)) LIMIT 1 ) AS note1_snippet, ( SELECT b2.content FROM "NoteEmbeddingChunk" b2 WHERE b2."noteId" = b."noteId" AND 1 - (a2."embedding"::vector <=> b2."embedding"::vector) = MAX(...) LIMIT 1 ) AS note2_snippet FROM "NoteEmbeddingChunk" a JOIN "NoteEmbeddingChunk" b ON a."noteId" < b."noteId" -- évite les doublons et self-joins JOIN "Note" na ON na.id = a."noteId" JOIN "Note" nb ON nb.id = b."noteId" WHERE na."userId" = $1 AND nb."userId" = $1 AND a."embedding" IS NOT NULL AND b."embedding" IS NOT NULL GROUP BY a."noteId", b."noteId" HAVING MAX(1 - (a."embedding"::vector <=> b."embedding"::vector)) >= $2 ``` > ⚠️ **Performance** : ce cross-join est coûteux. L'exécuter uniquement pour la note qui vient d'être indexée (pas sur toutes les paires à chaque fois). Pour la note N nouvellement indexée : `JOIN` ses fragments contre TOUS les autres fragments, `MAX()` par noteId cible, filtrer par seuil. #### Flux Memory Echo mis à jour : ``` Note N sauvegardée → chunks indexés → pour chaque autre note M : best_similarity = MAX over (fragment_N_i, fragment_M_j) if best_similarity >= THRESHOLD and passesTimeDiversityFilter(N, M): create MemoryEchoInsight with note1_snippet + note2_snippet ``` --- ### US-CHUNK-6 : Migration rétroactive des notes existantes **En tant qu'** admin/système, **Je veux** indexer en fragments toutes les notes qui ont déjà un embedding global, **Afin de** bénéficier de la précision fragment-level sur le corpus existant. #### Critères d'acceptation : - **Étant donné** que des notes ont un `NoteEmbedding` mais pas de `NoteEmbeddingChunk` - **Quand** l'admin lance le script de migration - **Alors** pour chaque note (batch de 50) : 1. Récupérer le plain text (titre + corps) 2. Chunker en fragments 3. Embedder chaque fragment (respect du rate-limit API) 4. Insérer en DB - **Et** une barre de progression affiche : `1234 / 5678 notes (21%) — ETA: ~8 min` - **Et** le script est **interruptible** (Ctrl+C safe — reprend là où il s'est arrêté via un checkpoint) - **Et** le script est **idempotent** (les notes déjà chunkées sont skip) - **Et** le script logge les erreurs par note mais continue - **Et** le coût API estimé est affiché avant confirmation #### Script : ```bash # Lancement manuel (pas en CI) npx tsx scripts/migrate-chunk-embeddings.ts --batch-size=50 --concurrency=4 ``` #### Estimation de coût : - ~1 note moyenne = ~3000 chars = ~4 fragments - `text-embedding-3-small` : $0.02 / 1M tokens ≈ $0.02 / 750K chars - 1000 notes × 4 fragments × 1000 chars = 4M chars ≈ $0.10 - **Négligeable** pour la plupart des corpus --- ### US-CHUNK-7 : Affichage des snippets dans la SearchModal **En tant qu'** utilisateur, **Je veux** voir les passages précis qui correspondent à ma recherche, **Afin de** évaluer rapidement la pertinence d'un résultat. #### Critères d'acceptation : - **Étant donné** des résultats de recherche avec `matchedSnippets` - **Quand** ils sont affichés dans la SearchModal - **Alors** sous chaque titre de note, les top 1-2 snippets sont affichés (max 150 chars visibles, `...` aux coupures) - **Et** le terme de recherche est **surligné en gras** dans les snippets - **Et** les snippets sont en couleur secondaire (text-muted, plus petit que le titre) - **Et** si aucun snippet fragment-level n'est disponible (note pas encore migrée), le comportement actuel est conservé (extrait du début de la note) #### Design : - Consulter le prototype `architectural-grid/` → `SearchModal` pour le design exact des snippets dans les résultats - Style Google-like : snippet gris sous le titre, terme de recherche en gras --- ### US-CHUNK-8 : Nettoyage à la suppression de note **En tant que** système, **Je veux** que les fragments d'une note supprimée soient automatiquement supprimés, **Afin de** ne pas laisser de vecteurs orphelins. #### Critères d'acceptation : - **Quand** une note est supprimée - **Alors** tous ses `NoteEmbeddingChunk` sont supprimés en cascade (`onDelete: Cascade` dans le schéma Prisma) - **Et** aucun vecteur orphelin ne subsiste - **Et** les Memory Echo référençant cette note sont nettoyés (comportement existant) > Le `onDelete: Cascade` sur la relation `note` dans le schéma gère cela automatiquement au niveau DB. --- ## Fichiers à créer / modifier | Fichier | Action | Notes | |---------|--------|-------| | `prisma/schema.prisma` | Modifier | Ajouter modèle `NoteEmbeddingChunk` + relation inverse sur `Note` | | `prisma/migrations/xxx_add_note_embedding_chunks/` | Créer | Migration + SQL brut pour index HNSW | | `lib/text/note-chunking.ts` | **Créer** | Logique de chunking sémantique + hash | | `lib/ai/services/chunk-indexing.service.ts` | **Créer** | Service d'indexation incrémentale (dedup, queue, retry) | | `lib/ai/services/semantic-search.service.ts` | Modifier | Ajouter `vectorChunkSearch()` + fusion RRF avec existant + snippets | | `lib/ai/services/memory-echo.service.ts` | Modifier | Similarité au niveau fragment + snippets dans insights | | `lib/ai/services/embedding.service.ts` | Modifier | Ajouter `generateChunkEmbeddings(texts: string[])` (batch optimisé) | | `app/api/notes/[id]/route.ts` (ou hook de save) | Modifier | Déclencher `chunkIndexingService.indexNote()` après save | | `components/search/search-result-item.tsx` (ou équivalent) | Modifier | Afficher `matchedSnippets` | | `scripts/migrate-chunk-embeddings.ts` | **Créer** | Script de migration rétroactive avec checkpoint | | `locales/en.json` + `locales/fr.json` | Modifier | Clés `search.snippets.*` (minimal) | --- ## Architecture technique ### Pipeline d'indexation (flow complet) ``` ┌─────────────────────────────────────────────────────────────────┐ │ Éditeur (TipTap) │ │ User tape → debounce 2s → POST /api/notes/[id] (save) │ └──────────────────────────┬──────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────┐ │ Note Save Handler (server-side) │ │ 1. Sauvegarde contenu en DB (existant) │ │ 2. Re-embed NoteEmbedding global (existant, conservé) │ │ 3. fire-and-forget → chunkIndexingService.indexNote(noteId) │ └──────────────────────────┬──────────────────────────────────────┘ │ (async, non-bloquant) ▼ ┌─────────────────────────────────────────────────────────────────┐ │ ChunkIndexingService.indexNote(noteId) │ │ ┌─────────────────────────────────────────────────────────┐ │ │ │ 1. Récupérer note (title + content) depuis DB │ │ │ │ 2. plain = prepareNoteTextForEmbedding(title, content) │ │ │ │ 3. chunks = chunkNoteContent(noteId, plain) │ │ │ │ → [{fragmentId, content, chunkIndex}, ...] │ │ │ │ 4. existingIds = SELECT fragmentId WHERE noteId │ │ │ │ 5. newChunks = chunks WHERE fragmentId NOT IN existing │ │ │ │ 6. staleIds = existingIds NOT IN chunks.fragmentIds │ │ │ │ 7. DELETE WHERE noteId AND fragmentId IN staleIds │ │ │ │ 8. Queue: embed newChunks → INSERT │ │ │ └─────────────────────────────────────────────────────────┘ │ └──────────────────────────┬──────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────┐ │ p-queue (concurrency: 4) │ │ Pour chaque nouveau fragment : │ │ embedding = await embeddingService.embedPlainText(content) │ │ INSERT INTO NoteEmbeddingChunk (fragmentId, content, │ │ embedding, ...) ON CONFLICT DO UPDATE │ │ Retry: 3 tentatives, backoff exponentiel (1s, 2s, 4s) │ └─────────────────────────────────────────────────────────────────┘ ``` ### Chunking — algorithme détaillé ```typescript // lib/text/note-chunking.ts import { createHash } from 'crypto' const CHUNK_TARGET_CHARS = 1000 const CHUNK_OVERLAP_CHARS = 200 const MIN_FRAGMENT_CHARS = 10 const MAX_PARAGRAPH_BEFORE_SPLIT = 1500 export interface NoteChunk { fragmentId: string content: string chunkIndex: number charCount: number } /** * Découpe une note en fragments sémantiques. * Port TS de flowy-ai/src/embeddings/document_indexer.rs */ export function chunkNoteContent( noteId: string, plainText: string, ): NoteChunk[] { const normalized = plainText.trim() if (normalized.length < MIN_FRAGMENT_CHARS) return [] // 1. Split par paragraphes (double newline) const paragraphs = normalized .split(/\n\s*\n/) .map((p) => p.trim()) .filter((p) => p.length >= MIN_FRAGMENT_CHARS) if (paragraphs.length === 0) return [] // 2. Sous-découper les paragraphes trop longs const atomicParagraphs: string[] = [] for (const para of paragraphs) { if (para.length > MAX_PARAGRAPH_BEFORE_SPLIT) { atomicParagraphs.push(...splitLongParagraph(para, CHUNK_TARGET_CHARS)) } else { atomicParagraphs.push(para) } } // 3. Grouper les paragraphes en chunks de ~CHUNK_TARGET_CHARS const groups = groupParagraphsByMaxContentLen( atomicParagraphs, CHUNK_TARGET_CHARS, CHUNK_OVERLAP_CHARS, ) // 4. Hash + construire les NoteChunk const chunks: NoteChunk[] = [] for (let i = 0; i < groups.length; i++) { const content = groups[i] if (content.length < MIN_FRAGMENT_CHARS) continue const fragmentId = hashFragment(noteId, content) // Dédup : si le même fragmentId existe déjà (paragraphe répété), skip if (chunks.some((c) => c.fragmentId === fragmentId)) continue chunks.push({ fragmentId, content, chunkIndex: i, charCount: content.length, }) } return chunks } function hashFragment(noteId: string, content: string): string { return createHash('sha256') .update(`${noteId}::${content}`) .digest('hex') .slice(0, 32) // 32 chars = 128 bits, largement suffisant pour le dedup } function splitLongParagraph(para: string, maxLen: number): string[] { // Coupe aux fins de phrase (. ! ? ؟ !。) les plus proches de maxLen const sentences = para.split(/(?<=[.!?؟!。])\s+/) const chunks: string[] = [] let current = '' for (const sentence of sentences) { if ((current + ' ' + sentence).length > maxLen && current) { chunks.push(current.trim()) current = sentence } else { current = current ? `${current} ${sentence}` : sentence } } if (current.trim()) chunks.push(current.trim()) // Si une phrase seule dépasse maxLen, coupe dur au mot le plus proche return chunks.flatMap((chunk) => chunk.length > maxLen * 1.5 ? hardSplitByWords(chunk, maxLen) : [chunk], ) } function hardSplitByWords(text: string, maxLen: string): string[] { // Coupe au mot le plus proche de maxLen // ... } /** * Groupe les paragraphes en chunks de taille ~maxContentLen * avec un overlap optionnel pour préserver le contexte. * Port de group_paragraphs_by_max_content_len (document_indexer.rs:147) */ function groupParagraphsByMaxContentLen( paragraphs: string[], maxLen: number, overlap: number, ): string[] { if (paragraphs.length === 0) return [] if (overlap > maxLen) overlap = maxLen / 2 const result: string[] = [] let current = '' for (const para of paragraphs) { if (current.length + para.length > maxLen && current) { result.push(current.trim()) // Préserve l'overlap : reprend les derniers `overlap` chars const tail = current.slice(-overlap) current = tail + para } else { current = current ? `${current}\n\n${para}` : para } } if (current.trim()) result.push(current.trim()) return result } ``` ### Recherche fragment-level — intégration dans SemanticSearchService ```typescript // Ajout à lib/ai/services/semantic-search.service.ts export interface SearchResult { noteId: string title: string | null content: string score: number matchType: 'exact' | 'related' language?: string | null matchedSnippets?: string[] // NOUVEAU — top fragments qui matchent } class SemanticSearchService { // ... existant ... /** * NOUVEAU — Recherche vectorielle au niveau fragment. * Retourne les notes triées par meilleure similarité de fragment. */ private async vectorChunkSearch( queryEmbedding: number[], userId: string, threshold: number, notebookId: string | null, limit: number, ): Promise { const vecStr = `[${queryEmbedding.join(',')}]` const params: any[] = [vecStr, userId, threshold, limit] let notebookJoin = '' if (notebookId) { notebookJoin = `AND n."notebookId" = $${params.length + 1}` params.push(notebookId) } const sql = ` WITH chunk_scores AS ( SELECT c."noteId", MAX(1 - (c."embedding"::vector <=> $1::vector)) AS best_score, ARRAY_AGG( c.content ORDER BY 1 - (c."embedding"::vector <=> $1::vector) DESC ) AS ranked_contents FROM "NoteEmbeddingChunk" c JOIN "Note" n ON n.id = c."noteId" WHERE n."userId" = $2 AND c."embedding" IS NOT NULL AND 1 - (c."embedding"::vector <=> $1::vector) >= $3 ${notebookJoin} GROUP BY c."noteId" LIMIT $4 ) SELECT cs."noteId", n.title, cs.best_score, cs.ranked_contents[1:3] AS top_snippets FROM chunk_scores cs JOIN "Note" n ON n.id = cs."noteId" ORDER BY cs.best_score DESC ` const rows = await prisma.$queryRawUnsafe(sql, ...params) return rows.map((row: any) => ({ noteId: row.noteId, title: row.title, content: row.top_snippets?.[0] ?? '', score: row.best_score, matchType: 'related' as const, matchedSnippets: (row.top_snippets ?? []).map((s: string) => s.length > 200 ? s.slice(0, 200) + '...' : s, ), })) } /** * Recherche hybride mise à jour : * FTS + vectorChunkSearch + vectorNoteSearch → RRF fusion */ async search(query: string, userId: string, opts: SearchOptions): Promise { // ... existant : FTS ... // NOUVEAU : recherche fragment-level en parallèle de la recherche note-level const [keywordResults, noteResults, chunkResults] = await Promise.all([ this.keywordSearch(query, userId, opts), this.vectorSearch(queryEmbedding, userId, opts), this.vectorChunkSearch(queryEmbedding, userId, opts.threshold, opts.notebookId, opts.limit), ]) // RRF sur les 3 sources const fused = this.reciprocalRankFusion([ ...keywordResults, ...noteResults, ...chunkResults, ]) // Merge snippets : si un résultat a des snippets des chunks, les propager return this.mergeSnippets(fused, chunkResults) } } ``` --- ## Risques et mitigations | Risque | Probabilité | Impact | Mitigation | |--------|-------------|--------|------------| | Cross-join fragment coûteux pour Memory Echo | Moyenne | Latence | N'exécuter que pour la note nouvellement indexée (pas sur tout le corpus à chaque fois) | | Coût API embedding x4-5 (plus de fragments) | Faible | $$ | Dedup par hash → seule une fraction est re-embeddée après la migration initiale | | Rate-limit API OpenAI | Moyenne | Blocage | Queue avec concurrence 4 + retry backoff exponentiel | | Migration longue sur gros corpus | Faible | UX admin | Script batch avec checkpoint, interruptible, reprise auto | | Index HNSW non créé (oubli migration) | Faible | Recherche lente | Vérifier l'existence de l'index au démarrage (`getDbDimension` déjà existe pour ça) | | Fragments orphelins si note déplacée entre carnets | Faible | Bruit | `onDelete: Cascade` + nettoyage au move (déjà géré pour `NoteEmbedding`) | --- ## Métriques à tracker | Métrique | Objectif | Mesure | |----------|----------|--------| | Précision Memory Echo | +30% connexions pertinentes | Taux de feedback positif sur les insights | | Latence recherche sémantique | < 200ms (P95) | Telemetry sur `semantic-search.service.ts` | | Coût API embedding / mois | Stable vs actuel | Compter les appels `embedPlainText` | | Snippets affichés dans recherche | > 80% des résultats | Telemetry sur SearchModal | | Fragments par note (moyenne) | 3-5 | Requête analytique sur `NoteEmbeddingChunk` | --- ## Clés i18n (minimal — la majorité est invisible côté UI) ```json { "search": { "snippets": { "matchedSection": "Section correspondante" } } } ``` > La fonctionnalité est principalement backend — peu de libellés visibles. Les snippets sont du contenu de note (pas du texte à traduire). --- ## Ordre d'implémentation recommandé 1. **Migration Prisma** — modèle `NoteEmbeddingChunk` + index HNSW 2. **`lib/text/note-chunking.ts`** — logique de chunking pure (testable isolément) 3. **`lib/ai/services/chunk-indexing.service.ts`** — service d'indexation (dedup, queue) 4. **Intégration au hook de save** — déclenchement async 5. **Script de migration** — indexer le corpus existant 6. **Recherche fragment-level** — `vectorChunkSearch` + fusion RRF + snippets 7. **UI snippets dans SearchModal** — affichage des passages précis 8. **Memory Echo fragment-level** — similarité par meilleure paire de fragments 9. **Validation end-to-end** — créer une note longue, vérifier chunks en DB, rechercher, Memory Echo --- ## Définition de Done (DoD) - [ ] Migration Prisma appliquée en dev, dump DB validé avant - [ ] `chunkNoteContent()` produit des fragments cohérents (vérifié sur 5 notes de test : courte, longue, multilingue FR/FA, HTML clippé, vide) - [ ] Indexation incrémentale : modifier une typo ne re-embed qu'un fragment (vérifié en DB) - [ ] Recherche sémantique retourne des `matchedSnippets` pertinents - [ ] SearchModal affiche les snippets avec le terme surligné - [ ] Memory Echo détecte des connexions au niveau section - [ ] Script de migration complète le corpus sans erreur - [ ] Aucune régression sur la recherche existante (note-level toujours fonctionnelle) - [ ] `NoteEmbedding` (global) toujours maintenu pour rétro-compat - [ ] Pas de `revalidatePath` systématique (mutations optimistes) - [ ] Pas de tests écrits sauf demande explicite (AGENTS.md) --- ## Notes - **Conservation de `NoteEmbedding`** : la table existante reste le source of truth pour le clustering (`clustering.service.ts`) et la recherche globale. Les chunks sont une **couche additive** qui améliore la précision, pas un remplacement. - **AppFlowy utilise `nomic-embed-text` (768 dims, local via Ollama)**. Memento utilise `text-embedding-3-small` (1536 dims, OpenAI). La dimension est différente — le schéma `NoteEmbeddingChunk` doit spécifier `vector(1536)`. - **Performance pgvector** : l'index HNSW est crucial pour les requêtes fragment-level. Sans index, un scan séquentiel sur des milliers de fragments serait prohibitif. L'index doit être créé dans la migration SQL brute. - **Persan / RTL** : le chunking par paragraphes fonctionne indépendamment de la langue. Le split par fin de phrase (`؟ !。`) couvre les scripts RTL et CJK. Vérifier que les embeddings `text-embedding-3-small` gèrent bien le persan (déjà validé pour la recherche existante).