Files
Momento/docs/story-chunk-embeddings.md
Antigravity 96e7902f01
Some checks failed
CI / Lint, Unit Tests & Build (push) Failing after 1m22s
CI / Deploy production (on server) (push) Has been skipped
feat: publication IA (magazine/brief/essay) + fixes critique
Publication IA:
- 4 templates (magazine, brief, essay, simple) avec CSS riche
- Rewrite IA (article/exercises/tutorial/reference/mixed)
- Modération avec timeout 12s + fallback safe
- Quotas publish_enhance par tier (basic=2, pro=15, business=100)
- Détection contenu stale (hash)
- Migration DB publishedContent/publishedTemplate/publishedSourceHash

Fixes:
- cheerio v1.2: Element -> AnyNode (domhandler), decodeEntities cast
- _isShared ajouté au type Note (champ virtuel serveur)
- callout colors PDF export: extraction fonction pure testable
- admin/published: guard note.userId null
- Cmd+S fonctionne en mode dialog (pas seulement fullPage)

i18n:
- 23 clés publish* traduites dans les 15 locales
- Extension Web Clipper: 13 locales mise à jour

Tests:
- callout-colors.test.ts (6 tests)
- note-visible-in-view.test.ts (5 tests)
- entitlements.test.ts + byok-entitlements.test.ts: mock usageLog + unstubAllEnvs
- 199/199 tests passent

Tracker: user-stories.md sync avec sprint-status.yaml
2026-06-28 07:32:57 +00:00

33 KiB
Raw Blame History

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

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 :
    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 :

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 fragmentIds existants : SELECT fragmentId FROM "NoteEmbeddingChunk" WHERE noteId = $1
    2. Chunk le contenu actuel → produit de nouveaux fragments avec leurs fragmentIds
    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 :

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 :

-- 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) :

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 :

# 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é

// 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

// 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<SearchResult[]> {
    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<SearchResult[]> {
    // ... 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)

{
  "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-levelvectorChunkSearch + 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).