mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 12:41:54 +00:00
ADR-004 : embeddings locaux sur CPU (fastembed ONNX, paraphrase-multilingual-MiniLM-L12-v2, 384 dims — les textes du client ne quittent jamais le serveur), pgvector dans le Postgres existant (RagChunk possédé par Prisma, migration r5_ia + état de corpus sur Document), génération opt-in (mode extractif par défaut : la recette passe sans clé API), service siop2-ai jamais exposé — joint par l'API NestJS seule (X-Service-Token). apps/ai (FastAPI + uv) : pipeline PDF MinIO → texte paginé (pypdf) → anonymisation D4 (e-mails, téléphones marocains, noms connus de la base, insensible casse/accents — fonction pure testée) → découpage avec chevauchement (testé) → embeddings → RagChunk localisé (« p. 42 », « bilan du 17/07 »). Bilans codés clôturés ingérés. Exclusion de corpus (D3) appliquée à l'ingestion ET à la lecture. 14 pytest + ruff, embeddeur déterministe en CI (aucun téléchargement), job CI ai (uv), deploy en dépend. Vérifié en réel avec le vrai modèle : corpus seedé réindexé en 7 s (PDF réel → 31 extraits paginés + 3 bilans), recherche sémantique concluante, e-mails → ⟨contact⟩, 0 identité dans les chunks (contrôle SQL). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
58 lines
1.8 KiB
Python
58 lines
1.8 KiB
Python
"""Recherche sémantique dans le corpus (pgvector, distance cosinus).
|
|
Ne renvoie QUE des extraits sourcés — la brique de « sourcé ou silencieux ».
|
|
"""
|
|
|
|
from dataclasses import dataclass
|
|
|
|
import asyncpg
|
|
|
|
from .embeddings import Embeddeur
|
|
from .ingestion import _vecteur_sql
|
|
|
|
|
|
@dataclass
|
|
class ExtraitTrouve:
|
|
source_type: str
|
|
document_id: str | None
|
|
work_order_id: str | None
|
|
titre: str # nom de fichier ou référence d'OT
|
|
locator: str
|
|
content: str
|
|
score: float # similarité cosinus (0..1)
|
|
|
|
|
|
async def chercher(
|
|
cnx: asyncpg.Connection,
|
|
embeddeur: Embeddeur,
|
|
question: str,
|
|
limite: int = 5,
|
|
) -> list[ExtraitTrouve]:
|
|
vecteur = _vecteur_sql(embeddeur.encoder([question])[0])
|
|
lignes = await cnx.fetch(
|
|
'''
|
|
SELECT c."sourceType", c."documentId", c."workOrderId", c.locator, c.content,
|
|
1 - (c.embedding <=> $1::vector) AS score,
|
|
COALESCE(d."fileName", wo.reference, '?') AS titre
|
|
FROM "RagChunk" c
|
|
LEFT JOIN "Document" d ON d.id = c."documentId"
|
|
LEFT JOIN "WorkOrder" wo ON wo.id = c."workOrderId"
|
|
WHERE c."documentId" IS NULL OR d."inCorpus" -- l'exclusion D3 s'applique aussi à la lecture
|
|
ORDER BY c.embedding <=> $1::vector
|
|
LIMIT $2
|
|
''',
|
|
vecteur,
|
|
limite,
|
|
)
|
|
return [
|
|
ExtraitTrouve(
|
|
source_type=ligne["sourceType"],
|
|
document_id=str(ligne["documentId"]) if ligne["documentId"] else None,
|
|
work_order_id=str(ligne["workOrderId"]) if ligne["workOrderId"] else None,
|
|
titre=ligne["titre"],
|
|
locator=ligne["locator"],
|
|
content=ligne["content"],
|
|
score=float(ligne["score"]),
|
|
)
|
|
for ligne in lignes
|
|
]
|