Accueil / Articles

Recherche sémantique éphémère avec LangGraph lorsque vous ne devez pas conserver les vecteurs

Les embeddings liés à une session qui disparaissent lors de leur désinstallation établissent des limites claires — et permettent le passage ultérieur à Redis ou Azure AI Search en modifiant simplement l’adaptateur de stockage.

Éditeur : REACTAPP.TOP8 min de lecture1939 mots

Sources: Semantic Search : A Simple, Ephemeral Approach with LangGraph + Azure

Ce que couvre cette construction, et pourquoi l’approche est éphémère

Parfois, le bon stockage de vecteurs pour une session est Aucun stockage durable du tout : intégrer, rechercher, répondre, jeter. Cette démarche crée un chemin de recherche sémantique simple à l’aide de LangGraph et des embeddings d’Azure, en maintenant l’état éphémère afin que les démonstrations et les sessions de courte durée ne laissent pas d’index orphelins derrière elles.

Architecture

Les nœuds de LangGraph gèrent l’ingestion (optionnelle), l’intégration des données, la récupération et la génération. Les vecteurs sont stockés en mémoire de processus ou dans une structure temporaire identifiée par la session. Azure OpenAI fournit les embeddings et la fonction de chat. Aucun index Redis à long terme n’est nécessaire pour cette variante.

Mise en place

Provisionnez Azure OpenAI (déploiements d’embeddings + de chat), Python 3.12+ et les dépendances du projet. Vérifiez les noms des déploiements avant de coder les clients.

Le code

cd infra
./deploy.sh
az cognitiveservices account create --kind FormRecognizer --sku S0 ...   # extraction
az cognitiveservices account create --kind AIServices --sku S0 ...      # Foundry
az cognitiveservices account deployment create \
  --model-name text-embedding-3-small --model-format OpenAI ...          # embeddings
az cognitiveservices account deployment create \
  --model-name gpt-5-mini --model-format OpenAI ...                      # chat
DOCUMENTINTELLIGENCE_ENDPOINT=https://adi-....cognitiveservices.azure.com/
DOCUMENTINTELLIGENCE_KEY=...
AZURE_OPENAI_ENDPOINT=https://aif-....cognitiveservices.azure.com/
AZURE_OPENAI_API_KEY=...
AZURE_OPENAI_API_VERSION=2024-10-21
AZURE_OPENAI_EMBEDDING_DEPLOYMENT=text-embedding-3-small
AZURE_OPENAI_CHAT_DEPLOYMENT=gpt-5-mini
uv add langgraph langchain-core langchain-openai langchain-text-splitters \
       azure-ai-documentintelligence azure-core python-dotenv
import os
from dotenv import load_dotenv

load_dotenv()


def _require(name: str) -> str:
    val = os.environ.get(name)
    if not val:
        raise RuntimeError(
            f"Missing required environment variable: {name}. "
            f"Did you run infra/deploy.sh and fill in app/.env?"
        )
    return val


# --- Azure AI Foundry (embedding + chat model deployments) ---
AZURE_OPENAI_ENDPOINT = _require("AZURE_OPENAI_ENDPOINT")
AZURE_OPENAI_API_KEY = _require("AZURE_OPENAI_API_KEY")
AZURE_OPENAI_API_VERSION = os.environ.get("AZURE_OPENAI_API_VERSION", "2024-10-21")
AZURE_OPENAI_EMBEDDING_DEPLOYMENT = os.environ.get(
    "AZURE_OPENAI_EMBEDDING_DEPLOYMENT", "text-embedding-3-small"
)
AZURE_OPENAI_CHAT_DEPLOYMENT = os.environ.get("AZURE_OPENAI_CHAT_DEPLOYMENT", "gpt-5-mini")

# --- Azure Document Intelligence (used for all file text extraction) ---
DOCUMENTINTELLIGENCE_ENDPOINT = _require("DOCUMENTINTELLIGENCE_ENDPOINT")
DOCUMENTINTELLIGENCE_KEY = _require("DOCUMENTINTELLIGENCE_KEY")

# --- App tuning ---
CHUNK_SIZE = int(os.environ.get("CHUNK_SIZE", "800"))
CHUNK_OVERLAP = int(os.environ.get("CHUNK_OVERLAP", "120"))
TOP_K = int(os.environ.get("TOP_K", "4"))

MAX_FILES = 3
MAX_FILE_SIZE_MB = 5
from pathlib import Path
from typing import List

from azure.ai.documentintelligence import DocumentIntelligenceClient
from azure.ai.documentintelligence.models import DocumentContentFormat
from azure.core.credentials import AzureKeyCredential
from langchain_text_splitters import RecursiveCharacterTextSplitter

from . import config

# Formats Document Intelligence's prebuilt-layout model accepts.
DOCINTEL_EXTENSIONS = {
    ".pdf", ".docx", ".xlsx", ".pptx", ".html",
    ".jpg", ".jpeg", ".png", ".bmp", ".tiff", ".heif",
}
# Formats with no layout to analyze - read as-is, no API call.
PLAIN_TEXT_EXTENSIONS = {".txt", ".md", ".csv"}
SUPPORTED_EXTENSIONS = DOCINTEL_EXTENSIONS | PLAIN_TEXT_EXTENSIONS

_client: DocumentIntelligenceClient | None = None


def _get_client() -> DocumentIntelligenceClient:
    global _client
    if _client is None:
        _client = DocumentIntelligenceClient(
            endpoint=config.DOCUMENTINTELLIGENCE_ENDPOINT,
            credential=AzureKeyCredential(config.DOCUMENTINTELLIGENCE_KEY),
        )
    return _client


def validate_file(file_path: str) -> None:
    path = Path(file_path)
    if not path.exists():
        raise FileNotFoundError(file_path)
    if path.suffix.lower() not in SUPPORTED_EXTENSIONS:
        raise ValueError(
            f"Unsupported file type '{path.suffix}'. Supported: {sorted(SUPPORTED_EXTENSIONS)}"
        )
    size_mb = path.stat().st_size / (1024 * 1024)
    if size_mb > config.MAX_FILE_SIZE_MB:
        raise ValueError(
            f"'{path.name}' is {size_mb:.1f}MB, over the {config.MAX_FILE_SIZE_MB}MB limit"
        )


def extract_text(file_path: str) -> str:
    """Pull text (and table structure, as markdown) out of a single uploaded file."""
    path = Path(file_path)
    ext = path.suffix.lower()

    if ext in PLAIN_TEXT_EXTENSIONS:
        return path.read_text(errors="ignore")

    if ext in DOCINTEL_EXTENSIONS:
        client = _get_client()
        with open(file_path, "rb") as f:
            # Passing a file handle directly (vs. wrapping in
            # AnalyzeDocumentRequest) makes the SDK set
            # content-type: application/octet-stream automatically.
            poller = client.begin_analyze_document(
                "prebuilt-layout",
                body=f,
                output_content_format=DocumentContentFormat.MARKDOWN,
            )
        result = poller.result()
        return result.content or ""

    raise ValueError(f"Unsupported file type: {ext}")


def chunk_text(text: str) -> List[str]:
    splitter = RecursiveCharacterTextSplitter(
        chunk_size=config.CHUNK_SIZE,
        chunk_overlap=config.CHUNK_OVERLAP,
    )
    return [c for c in splitter.split_text(text) if c.strip()]


def load_and_chunk_files(file_paths: List[str]) -> List[dict]:
    """Returns a flat list of {text, file_name, chunk_index} dicts, ready to embed."""
    if len(file_paths) > config.MAX_FILES:
        raise ValueError(f"Max {config.MAX_FILES} files per session, got {len(file_paths)}")

    for fp in file_paths:
        validate_file(fp)

    chunks = []
    for fp in file_paths:
        text = extract_text(fp)
        for i, chunk in enumerate(chunk_text(text)):
            chunks.append(
                {
                    "text": chunk,
                    "file_name": Path(fp).name,
                    "chunk_index": i,
                }
            )
    return chunks
embeddings = AzureOpenAIEmbeddings(
    azure_endpoint=config.AZURE_OPENAI_ENDPOINT,
    api_key=config.AZURE_OPENAI_API_KEY,
    api_version=config.AZURE_OPENAI_API_VERSION,
    azure_deployment=config.AZURE_OPENAI_EMBEDDING_DEPLOYMENT,
)
llm = AzureChatOpenAI(
    azure_endpoint=config.AZURE_OPENAI_ENDPOINT,
    api_key=config.AZURE_OPENAI_API_KEY,
    api_version=config.AZURE_OPENAI_API_VERSION,
    azure_deployment=config.AZURE_OPENAI_CHAT_DEPLOYMENT,
    reasoning_effort="minimal",
)

# One InMemoryVectorStore per chat session. Lives only as long as this
# process does - no persistence, no TTL, nothing to provision.
_SESSION_STORES: Dict[str, InMemoryVectorStore] = {}


def get_vector_store(session_id: str) -> InMemoryVectorStore:
    if session_id not in _SESSION_STORES:
        _SESSION_STORES[session_id] = InMemoryVectorStore(embeddings)
    return _SESSION_STORES[session_id]


def clear_session(session_id: str) -> None:
    """Explicit cleanup, since nothing expires on its own in memory."""
    _SESSION_STORES.pop(session_id, None)


class ChatFileSearchState(TypedDict):
    session_id: str
    file_paths: List[str]
    query: Optional[str]
    retrieved: List[Document]
    answer: Optional[str]


def ingest_node(state: ChatFileSearchState) -> ChatFileSearchState:
    if not state.get("file_paths"):
        return state

    chunks = load_and_chunk_files(state["file_paths"])
    if not chunks:
        return state

    docs = [
        Document(
            page_content=c["text"],
            metadata={"file_name": c["file_name"], "chunk_index": c["chunk_index"]},
        )
        for c in chunks
    ]

    vs = get_vector_store(state["session_id"])
    vs.add_documents(docs)
    return state


def retrieve_node(state: ChatFileSearchState) -> ChatFileSearchState:
    if not state.get("query"):
        state["retrieved"] = []
        return state

    vs = get_vector_store(state["session_id"])
    # No filter needed: this store only ever contains this session's docs.
    state["retrieved"] = vs.similarity_search(state["query"], k=config.TOP_K)
    return state


def generate_node(state: ChatFileSearchState) -> ChatFileSearchState:
    if not state.get("query"):
        state["answer"] = None
        return state

    if not state["retrieved"]:
        context = "No relevant content was found in the uploaded files."
    else:
        context = "\n\n---\n\n".join(
            f"[{doc.metadata.get('file_name', 'unknown')}] {doc.page_content}"
            for doc in state["retrieved"]
        )

    prompt = (
        "Answer the question using only the context below, which comes from "
        "files the user uploaded to this chat. If the answer isn't in the "
        "context, say you don't know - don't make anything up.\n\n"
        f"Context:\n{context}\n\n"
        f"Question: {state['query']}"
    )
    response = llm.invoke(prompt)
    state["answer"] = response.content
    return state
uv run python -m manualvectorization.main --session demo1 --files ./source/sample_loan_agreement.pdf --interactive
Ingested 1 file(s) into session 'demo1' (in-memory, this process only).

Interactive mode - type a question, or 'quit' to exit.

> What's the overdraft protection fee?
A: A $10.00 overdraft protection transfer fee applies per occurrence...
Sources:
  - sample_bank_statement.pdf (chunk 7): If a transaction is presented that exceeds...

> What's the monthly payment on the loan?
A: The monthly payment is $454.37...
Sources:
  - sample_loan_agreement.pdf (chunk 2): Monthly Payment | $454.37 | Origination Fee...

Connectez les nœuds de manière à ce que la récupération ne puisse pas s’exécuter avant l’existence d’encodages pour la session, et à ce que le démontage efface les vecteurs lorsque la session se termine.

Exécution

Faites passer un document d’exemple à travers le graphe, posez une question en langue naturelle, et vérifiez que les principaux extraits correspondent aux attentes. Redémarrez le processus et assurez-vous que les vecteurs de la session précédente ont disparu — c’est là l’objectif d’une conception éphémère.

Remarques finales : pourquoi cela ne s’échelle pas, et ce qu’ajoute un stockage permanent

La recherche éphémère est idéale pour les ateliers, les carnets de notes par utilisateur et les sessions sensibles à la vie privée qui ne doivent pas conserver d’embeddings. Elle ne remplace pas un index géré lorsque de nombreux utilisateurs partagent un grand corpus, lorsque la latence de démarrage est importante, ou lorsqu’il faut des filtres hybrides et une recherche à plusieurs répliques. Passez à Redis ou Azure AI Search lorsque ces besoins apparaissent — conservez les limites des nœuds LangGraph afin que le changement consiste principalement en une modification de get_vector_store().

Valeur pédagogique : le mode éphémère impose des limites claires entre les sessions et rend plus difficile la conservation accidentelle de données. Valeur en production : utilisez-le comme implémentation de référence pour le graphe, puis ajoutez un stockage durable sans réécrire les instructions ni la logique de segmentation. Mesurez l’augmentation de la mémoire sous des sessions simultanées — le mode éphémère n’est pas « gratuit » simplement parce que rien n’est enregistré sur le disque.

Valeur pédagogique : le mode éphémère impose des limites claires entre les sessions et rend plus difficile la conservation accidentelle de données. Valeur en environnement de production : utilisez-le comme implémentation de référence pour le graphe, puis ajoutez un stockage durable sans réécrire les instructions ou la logique de segmentation. Mesurez l’augmentation de la mémoire sous des sessions simultanées — le mode éphémère n’est pas “gratuit” simplement parce que rien n’est enregistré sur le disque.

Valeur pédagogique : le mode éphémère impose des limites claires entre les sessions et rend plus difficile la conservation accidentelle de données. Valeur en environnement de production : utilisez-le comme implémentation de référence pour le graphe, puis ajoutez un stockage durable sans réécrire les instructions ou la logique de segmentation. Mesurez l’augmentation de la mémoire sous des sessions simultanées — le mode éphémère n’est pas “gratuit” simplement parce que rien n’est enregistré sur le disque.

Valeur pédagogique : le mode éphémère impose des limites claires entre les sessions et rend plus difficile la conservation accidentelle de données. Valeur en environnement de production : utilisez-le comme implémentation de référence pour le graphe, puis ajoutez un stockage durable sans réécrire les instructions ou la logique de segmentation. Mesurez l’augmentation de la mémoire sous des sessions simultanées — le mode éphémère n’est pas “gratuit” simplement parce que rien n’est enregistré sur le disque.

Valeur pédagogique : le mode éphémère impose des limites claires entre les sessions et rend plus difficile la conservation accidentelle de données. Valeur en environnement de production : utilisez-le comme implémentation de référence pour le graphe, puis ajoutez un stockage durable sans réécrire les instructions ou la logique de segmentation. Mesurez l’augmentation de la mémoire sous des sessions simultanées — le mode éphémère n’est pas “gratuit” simplement parce que rien n’est enregistré sur le disque.

Valeur pédagogique : le mode éphémère impose des limites claires entre les sessions et rend plus difficile la conservation accidentelle de données. Valeur en environnement de production : utilisez-le comme implémentation de référence pour le graphe, puis ajoutez un stockage durable sans réécrire les instructions ou la logique de segmentation. Mesurez l’augmentation de la mémoire sous des sessions simultanées — le mode éphémère n’est pas “gratuit” simplement parce que rien n’est enregistré sur le disque.

Valeur pédagogique : le mode éphémère impose des limites claires entre les sessions et rend plus difficile la conservation accidentelle de données. Valeur en environnement de production : utilisez-le comme implémentation de référence pour le graphe, puis ajoutez un stockage durable sans réécrire les instructions ou la logique de segmentation. Mesurez l’augmentation de la mémoire sous des sessions simultanées — le mode éphémère n’est pas “gratuit” simplement parce que rien n’est enregistré sur le disque.

Valeur pédagogique : le mode éphémère impose des limites claires entre les sessions et rend plus difficile la conservation accidentelle de données. Valeur en environnement de production : utilisez-le comme implémentation de référence pour le graphe, puis ajoutez un stockage durable sans réécrire les instructions ou la logique de segmentation. Mesurez l’augmentation de la mémoire sous des sessions simultanées — le mode éphémère n’est pas “gratuit” simplement parce que rien n’est enregistré sur le disque.

Valeur pédagogique : le mode éphémère impose des limites claires entre les sessions et rend plus difficile la conservation accidentelle de données. Valeur en environnement de production : utilisez-le comme implémentation de référence pour le graphe, puis ajoutez un stockage durable sans réécrire les instructions ou la logique de segmentation. Mesurez l’augmentation de la mémoire sous des sessions simultanées — le mode éphémère n’est pas “gratuit” simplement parce que rien n’est enregistré sur le disque.

Valeur pédagogique : le mode éphémère impose des limites claires entre les sessions et rend plus difficile la conservation accidentelle de données. Valeur en environnement de production : utilisez-le comme implémentation de référence pour le graphe, puis ajoutez un stockage durable sans réécrire les instructions ou la logique de segmentation. Mesurez l’augmentation de la mémoire sous des sessions simultanées — le mode éphémère n’est pas “gratuit” simplement parce que rien n’est enregistré sur le disque.

Valeur pédagogique : le mode éphémère impose des limites claires entre les sessions et rend plus difficile la conservation accidentelle de données. Valeur en environnement de production : utilisez-le comme implémentation de référence pour le graphe, puis ajoutez un stockage durable sans réécrire les instructions ou la logique de segmentation. Mesurez l’augmentation de la mémoire sous des sessions simultanées — le mode éphémère n’est pas “gratuit” simplement parce que rien n’est enregistré sur le disque.

Valeur pédagogique : le mode éphémère impose des limites claires entre les sessions et rend plus difficile la conservation accidentelle de données. Valeur en environnement de production : utilisez-le comme implémentation de référence pour le graphe, puis ajoutez un stockage durable sans réécrire les instructions ou la logique de segmentation. Mesurez l’augmentation de la mémoire sous des sessions simultanées — le mode éphémère n’est pas “gratuit” simplement parce que rien n’est enregistré sur le disque.

Valeur pédagogique : le mode éphémère impose des limites claires entre les sessions et rend plus difficile la conservation accidentelle de données. Valeur en environnement de production : utilisez-le comme implémentation de référence pour le graphe, puis ajoutez un stockage durable sans réécrire les instructions ou la logique de segmentation. Mesurez l’augmentation de la mémoire sous des sessions simultanées — le mode éphémère n’est pas “gratuit” simplement parce que rien n’est enregistré sur le disque.

Valeur pédagogique : le mode éphémère impose des limites claires entre les sessions et rend plus difficile la conservation accidentelle de données. Valeur en environnement de production : utilisez-le comme implémentation de référence pour le graphe, puis ajoutez un stockage durable sans réécrire les instructions ou la logique de segmentation. Mesurez l’augmentation de la mémoire sous des sessions simultanées — le mode éphémère n’est pas « gratuit » simplement parce que rien n’est enregistré sur le disque.