Accueil / Articles

Q&R sémantique PDF avec l’intelligence documentaire, les embeddings et Redis

LangGraph extrait les PDF de calendrier des frais, les divise en parties et les intègre avec Azure OpenAI, tout en répondant aux questions relatives aux frais formulées en langage naturel à partir d’un index vectoriel Redis local.

Éditeur : REACTAPP.TOP11 min de lecture2476 mots

Sources: Building a PDF Q&A Pipeline with Azure AI and Redis Vector Search

Les organisations sont submergées de PDF non structurés — des tableaux de tarifs, des informations divulguées, des contrats, des politiques — que la recherche par mots-clés traite mal. Ce projet crée un petit pipeline permettant de rendre un PDF recherchable sémantiquement : une question en langue naturelle affiche les passages les plus pertinents, même lorsque la formulation diffère.

Organisé sous forme de graphe d’états LangGraph, ce pipeline :

  1. Extrait le texte à l’aide de Azure AI Document Intelligence (texte brut et tableaux).
  2. Découpe le texte en passages chevauchants de la taille adaptée au modèle.
  3. Intègre ces données avec Azure OpenAI text-embedding-3-small.
  4. Stocke et recherche les vecteurs dans un Redis local (ou distant) via RediSearch.

Gardez l’ensemble délibérément compact : quelques fichiers Python, des infrastructures locales sur ordinateur portable, à part les deux appels à Azure AI.

Le test du bout en bout a utilisé un tableau de frais réel pour les cartes de dépenses de la U.S. Bank sur une seule page. Question : « quel est le frais de retrait à un distributeur hors réseau ? » — le montant de 2,50 $ a obtenu la première place en termes de similarité.

Pourquoi Redis comme stockage vectoriel

Redis est célèbre en tant que stockage KV en mémoire ; RediSearch (dans redis-stack) ajoute l’indexation vectorielle. Voici les raisons pour lesquelles il convient :

  • Vitesse — latence en mémoire pour des recherches interactives
  • Un seul système, deux fonctions — de nombreuses applications utilisent déjà Redis pour le cache ou les sessions
  • Indexation flexible — FLAT ou HNSW
  • Recherche hybride — vecteurs associés à des étiquettes, des filtres numériques et de texte complet
  • Fonctionne partout — conteneur pour ordinateur portable vers cloud géré, avec la même structure de requête
  • Alternatives à Redis pour la recherche

    Les bases de données vectorielles gérées et Azure AI Search constituent d’excellentes mises à niveau ultérieures ; Redis permet de garder le cycle d’apprentissage local et rapide.

    Prérequis

    Docker ; Azure CLI (az login) ; accès à Azure OpenAI ; Python 3.12 (3.14 a brisé cette pile de dépendances lors des tests) ; uv pour la gestion de l’environnement.

    Mettre en place des ressources Azure

    • Groupe de ressources rg-pdf-rag (par exemple eastus)
    • Document Intelligence docintel-pdf-rag (FormRecognizer/S0)
    • Azure OpenAI openai-pdf-rag avec une déploiement d’embeddings

    Démarrer Redis localement

    docker compose up -d
    
    mkdir chat-file-search-inmemory && cd chat-file-search-inmemory
    uv init .
    touch azure_resource.sh inspect_redis.py persist_vector_graph.py search.py
    code .
    
    sed -i 's/requires-python = ">=3.14"/requires-python = ">=3.12"/' pyproject.toml
    uv python pin 3.12
    uv python install 3.12
    rm -rf .venv uv.lock
    uv sync
    uv run python --version
    
    uv add langgraph langchain langchain-openai langchain-text-splitters \
           azure-ai-documentintelligence redis redisvl numpy python-dotenv
    
    AZURE_DOCINTEL_ENDPOINT=https://docintel-pdf-rag-78ee2.cognitiveservices.azure.com/
    AZURE_DOCINTEL_KEY=<rotated>
    AZURE_OPENAI_ENDPOINT=https://openai-pdf-rag-302a6.openai.azure.com/
    AZURE_OPENAI_API_KEY=<rotated>
    AZURE_OPENAI_API_VERSION=2023-05-15
    AZURE_OPENAI_EMBEDDING_DEPLOYMENT=text-embedding-3-small
    REDIS_URL=redis://localhost:6379
    REDIS_INDEX_NAME=pdf_docs
    
    import os
    import sys
    from typing import TypedDict, List, Optional
    
    import numpy as np
    from dotenv import load_dotenv
    from langgraph.graph import StateGraph, END
    
    from azure.ai.documentintelligence import DocumentIntelligenceClient
    from azure.ai.documentintelligence.models import AnalyzeDocumentRequest
    from azure.core.credentials import AzureKeyCredential
    
    from langchain_text_splitters import RecursiveCharacterTextSplitter
    from langchain_openai import AzureOpenAIEmbeddings
    
    from redisvl.index import SearchIndex
    from redisvl.schema import IndexSchema
    
    load_dotenv()
    
    REDIS_URL = os.environ["REDIS_URL"]
    REDIS_INDEX_NAME = os.environ.get("REDIS_INDEX_NAME", "pdf_docs")
    
    EMBEDDING_DIMS = 1536
    
    
    def get_index_schema() -> IndexSchema:
        return IndexSchema.from_dict({
            "index": {"name": REDIS_INDEX_NAME, "prefix": REDIS_INDEX_NAME},
            "fields": [
                {"name": "content", "type": "text"},
                {"name": "source", "type": "tag"},
                {"name": "chunk_index", "type": "numeric"},
                {
                    "name": "embedding",
                    "type": "vector",
                    "attrs": {
                        "dims": EMBEDDING_DIMS,
                        "distance_metric": "cosine",
                        "algorithm": "flat",
                        "datatype": "float32",
                    },
                },
            ],
        })
    
    
    def vector_to_bytes(vector: List[float]) -> bytes:
        return np.array(vector, dtype=np.float32).tobytes()
    
    
    class PipelineState(TypedDict):
        file_path: str
        text: Optional[str]
        chunks: Optional[List[str]]
        num_stored: Optional[int]
    
    
    def extract_text_node(state: PipelineState) -> PipelineState:
        client = DocumentIntelligenceClient(
            endpoint=os.environ["AZURE_DOCINTEL_ENDPOINT"],
            credential=AzureKeyCredential(os.environ["AZURE_DOCINTEL_KEY"]),
        )
    
        with open(state["file_path"], "rb") as f:
            poller = client.begin_analyze_document(
                "prebuilt-layout",
                AnalyzeDocumentRequest(bytes_source=f.read()),
            )
        result = poller.result()
    
        state["text"] = result.content
        return state
    
    
    def chunk_text_node(state: PipelineState) -> PipelineState:
        splitter = RecursiveCharacterTextSplitter(
            chunk_size=1000,
            chunk_overlap=150,
        )
        state["chunks"] = splitter.split_text(state["text"])
        return state
    
    
    def embed_and_store_node(state: PipelineState) -> PipelineState:
        embeddings = AzureOpenAIEmbeddings(
            azure_endpoint=os.environ["AZURE_OPENAI_ENDPOINT"],
            api_key=os.environ["AZURE_OPENAI_API_KEY"],
            api_version=os.environ["AZURE_OPENAI_API_VERSION"],
            azure_deployment=os.environ["AZURE_OPENAI_EMBEDDING_DEPLOYMENT"],
        )
    
        chunks = state["chunks"]
        vectors = embeddings.embed_documents(chunks)
    
        index = SearchIndex(get_index_schema(), redis_url=REDIS_URL)
        index.create(overwrite=False)
    
        records = [
            {
                "content": chunk,
                "source": state["file_path"],
                "chunk_index": i,
                "embedding": vector_to_bytes(vector),
            }
            for i, (chunk, vector) in enumerate(zip(chunks, vectors))
        ]
    
        index.load(records)
    
        state["num_stored"] = len(records)
        return state
    
    
    def build_graph():
        graph = StateGraph(PipelineState)
    
        graph.add_node("extract_text", extract_text_node)
        graph.add_node("chunk_text", chunk_text_node)
        graph.add_node("embed_and_store", embed_and_store_node)
    
        graph.set_entry_point("extract_text")
        graph.add_edge("extract_text", "chunk_text")
        graph.add_edge("chunk_text", "embed_and_store")
        graph.add_edge("embed_and_store", END)
    
        return graph.compile()
    
    
    if __name__ == "__main__":
        if len(sys.argv) != 2:
            print("Usage: python persist_vector_graph.py path/to/file.pdf")
            sys.exit(1)
    
        pdf_path = sys.argv[1]
        app = build_graph()
        result = app.invoke({"file_path": pdf_path})
    
        print(f"Extracted {len(result['text'])} characters")
        print(f"Split into {len(result['chunks'])} chunks")
        print(f"Stored {result['num_stored']} vectors in Redis index '{REDIS_INDEX_NAME}'")
    
    curl -L -o usbank_expense_card_fee_schedule.pdf \
      "https://www.usbankexpensecard.com/documents/792269/792586/Fee+Schedule_Cash_197196658_7.23.19.pdf/8388d9e3-8225-8017-152a-c9cb601d626f"
    
    uv run python persist_vector_graph.py ./usbank_expense_card_fee_schedule.pdf
    # Execution results
    Extracted 5466 characters
    Split into 7 chunks
    Stored 7 vectors in Redis index 'pdf_docs'
    
    uv run inspect_redis.py
    
    # Execution results
    
    Index: pdf_docs
      Documents in index: 7
      Fields: ['content', 'source', 'chunk_index', 'embedding']
    
    Found 7 stored chunks. Showing up to 10:
    
    --- pdf_docs:01KZ28WSFJZFGWTPADP0MWZASG ---
    source      : ./usbank_expense_card_fee_schedule.pdf
    chunk_index : 5
    content     : $3.00
    This is our fee charged each month after you have not completed a transaction using your card for 180 consecutive days.
    Transaction Limits
    For security reasons, there are limitations on the number and amount of transactions that you may perform with your U.S. Bank card. Daily limits are based ...
    
    --- pdf_docs:01KZ28WSFJZFGWTPADP0MWZASC ---
    source      : ./usbank_expense_card_fee_schedule.pdf
    chunk_index : 1
    content     : . Memorize the Personal Identification Number (PIN) that you will establish . Sign your name in ink on the back of the card
    Card Usage Tips: Gas Stations: When purchasing gasoline at a gas station using the pay-at-the-pump option, a maximum hold of $75.00 will be placed on your account to initiate y...
    
    --- pdf_docs:01KZ28WSFJZFGWTPADP0MWZASD ---
    source      : ./usbank_expense_card_fee_schedule.pdf
    chunk_index : 2
    content     : Hotels: When making travel reservations with a hotel or similar merchant, ask for the amount of the authorization they will send to your account. These merchants may send an initial authorization amount equal to your entire stay or rental period, plus taxes and incidentals, even though your actual p...
    
    --- pdf_docs:01KZ28WSFJZFGWTPADP0MWZASE ---
    source      : ./usbank_expense_card_fee_schedule.pdf
    chunk_index : 3
    content     : ATM Withdrawal (out-of-network)
    $2.50
    This is our fee per withdrawal. "Out-of-network" refers to all the ATMs outside of the U.S. Bank or MoneyPass ATM networks. You may also be charged a fee by the ATM operator even if you do not complete a transaction.
    Teller Cash Withdrawal
    $5.00
    This is our fee ...
    
    --- pdf_docs:01KZ28WSFJZFGWTPADP0MWZASH ---
    source      : ./usbank_expense_card_fee_schedule.pdf
    chunk_index : 6
    content     : 20 transactions and $5,050 per day
    Teller withdrawals (at Visa member banks) (Financial Institutions may have lower limits)
    3 transactions and $5,050 per day
    Maximum daily credits
    10 transactions and $10,000 per day
    Returns and Refunds
    May not exceed 4 transactions per day
    USBEXCV9
    The U.S. Bank Exp...
    the
    --- pdf_docs:01KZ28WSFJZFGWTPADP0MWZASB ---
    source      : ./usbank_expense_card_fee_schedule.pdf
    chunk_index : 0
    content     : Welcome! To your new U.S. Bank Expense Card - Cash Access
    Your card can be used anywhere Visa debit cards are accepted.
    Card Checklist :unselected: Activate your card □ □ :unselected: Set up your online account □ :unselected: Sign up for text1 or email alerts
    See the enclosed Usage Guide for more de...
    
    --- pdf_docs:01KZ28WSFJZFGWTPADP0MWZASF ---
    source      : ./usbank_expense_card_fee_schedule.pdf
    chunk_index : 4
    content     : Using your card outside the U.S.
    International Transaction
    3%
    This is our fee which applies when you use your card for purchases at foreign merchants and for cash withdrawals from foreign ATMs and is a percentage of the transaction dollar amount, after any currency conversion. Some transactions, eve...
    
    import os
    import sys
    
    import numpy as np
    from dotenv import load_dotenv
    from langchain_openai import AzureOpenAIEmbeddings
    
    from redisvl.index import SearchIndex
    from redisvl.query import VectorQuery
    
    from persist_vector_graph import get_index_schema, vector_to_bytes
    
    load_dotenv()
    
    REDIS_URL = os.environ["REDIS_URL"]
    REDIS_INDEX_NAME = os.environ.get("REDIS_INDEX_NAME", "pdf_docs")
    
    
    def get_embeddings() -> AzureOpenAIEmbeddings:
        return AzureOpenAIEmbeddings(
            azure_endpoint=os.environ["AZURE_OPENAI_ENDPOINT"],
            api_key=os.environ["AZURE_OPENAI_API_KEY"],
            api_version=os.environ["AZURE_OPENAI_API_VERSION"],
            azure_deployment=os.environ["AZURE_OPENAI_EMBEDDING_DEPLOYMENT"],
        )
    
    
    if __name__ == "__main__":
        if len(sys.argv) < 2:
            print('Usage: python search.py "your question here"')
            sys.exit(1)
    
        query_text = " ".join(sys.argv[1:])
    
        embeddings = get_embeddings()
        query_vector = embeddings.embed_query(query_text)
    
        index = SearchIndex(get_index_schema(), redis_url=REDIS_URL)
    
        vq = VectorQuery(
            vector=vector_to_bytes(query_vector),
            vector_field_name="embedding",
            return_fields=["content", "source", "chunk_index"],
            num_results=4,
        )
    
        results = index.query(vq)
    
        for i, r in enumerate(results, start=1):
            print(f"\n--- Result {i} (score={r.get('vector_distance')}) ---")
            print(f"Source: {r.get('source')} | chunk {r.get('chunk_index')}")
            print(r.get("content", "")[:500])
    
    uv run python search.py "what is the ATM withdrawal fee out of network?"Output:
    
    --- Result 1 (score=0.301177144051) ---
    Source: ./usbank_expense_card_fee_schedule.pdf | chunk 3
    ATM Withdrawal (out-of-network)
    $2.50
    This is our fee per withdrawal. "Out-of-network" refers to all the ATMs
    outside of the U.S. Bank or MoneyPass ATM networks. You may also be charged
    a fee by the ATM operator even if you do not complete a transaction.
    Teller Cash Withdrawal
    $5.00
    ...
    --- Result 2 (score=0.400232374668) ---
    Source: ./usbank_expense_card_fee_schedule.pdf | chunk 4
    Using your card outside the U.S.
    International Transaction
    3%
    ...
    --- Result 3 (score=0.4464854002) ---
    Source: ./usbank_expense_card_fee_schedule.pdf | chunk 2
    Hotels: When making travel reservations with a hotel or similar merchant...
    --- Result 4 (score=0.517238736153) ---
    Source: ./usbank_expense_card_fee_schedule.pdf | chunk 5
    $3.00
    This is our fee charged each month after you have not completed a
    transaction using your card for 180 consecutive days.
    ...
    

    Environnement Python avec uv

    Fixez soigneusement les versions. Les intégrations Redis de LangChain obsolètes posaient de réels problèmes — préférez l’ensemble de paquets maintenus décrit dans le dépôt associé et vérifiez les imports avant d’écrire des nœuds de graphe.

    Variables d’environnement

    Indiquez l’endpoint/la clé de Document Intelligence, l’endpoint/la clé d’Azure OpenAI, le nom du déploiement des embeddings (et non le nom du modèle de base), ainsi que l’URL de Redis.

    Document d’échantillon et exécution du pipeline

    Faites passer le PDF contenant le calendrier des frais par les étapes extract → chunk → embed → upsert. Vérifiez que le nombre de clés Redis correspond au nombre de chunks avant de rechercher.

    Mettre en œuvre une recherche sémantique

    Intégrez la question avec la même méthode de déploiement, exécutez KNN sur l’index, et affichez les meilleurs extraits avec leurs scores. Vérifiez que la réponse concernant la commission de l’ATM occupe la première place pour la question d’or.

    Leçons apprises

    • Les différences de version de Python perturbent discrètement les modules préinstallés — utilisez la version 3.12 pour cette stack.
    • Dans les clients Azure OpenAI, le nom de déploiement n’est pas identique au nom du modèle.
    • L’overlap lors du découpage des données est important pour les lignes de frais ressemblant à des tableaux qui dépassent les limites entre blocs.
    • Redis local est idéal pour les démonstrations ; en production, il faut néanmoins une persistance, des mécanismes d’authentification et des sauvegardes.
    • LangGraph s’avère utile lorsque l’on souhaite pouvoir inspecter l’état des nœuds entre l’extraction et la mise à jour.

    Que faire ensuite ?

    Remplacez Redis par Azure AI Search, ajoutez des contrôles de protection des données personnelles sur les requêtes, le contexte et les réponses, et passez à une API hébergée pour le graphe. Conservez la question relative au calendrier des frais d’or dans les tests continus afin que les migrations ne perturbent pas silencieusement la récupération des données.

    Conseil de segmentation pour les tableaux des frais : privilégiez des chevauchements légèrement plus importants lorsque Document Intelligence renvoie du texte orienté vers des cellules, et stockez les numéros de page dans les métadonnées Redis afin que les réponses puissent citer « page 1, frais d’ATM » plutôt que des extraits isolés. Ces métadonnées permettent également l’utilisation de filtres hybrides lorsque plusieurs PDF partagent un même index.

    Conseil de segmentation pour les tableaux des frais : privilégiez des chevauchements légèrement plus importants lorsque Document Intelligence renvoie du texte orienté vers des cellules, et stockez les numéros de page dans les métadonnées Redis afin que les réponses puissent citer « page 1, frais d’ATM » plutôt que des extraits isolés. Ces métadonnées permettent également l’utilisation de filtres hybrides lorsque plusieurs PDF partagent un même index.

    Conseil de segmentation pour les tableaux des frais : privilégiez des chevauchements légèrement plus importants lorsque Document Intelligence renvoie du texte orienté vers les cellules, et stockez les numéros de page dans les métadonnées Redis afin que les réponses puissent citer « page 1, frais d’ATM » plutôt que des extraits isolés. Ces métadonnées permettent également l’utilisation de filtres hybrides lorsque plusieurs PDF partagent un même index.

    Conseil de segmentation pour les tableaux des frais : privilégiez des chevauchements légèrement plus importants lorsque Document Intelligence renvoie du texte orienté vers les cellules, et stockez les numéros de page dans les métadonnées Redis afin que les réponses puissent citer « page 1, frais d’ATM » plutôt que des extraits isolés. Ces métadonnées permettent également l’utilisation de filtres hybrides lorsque plusieurs PDF partagent un même index.

    Conseil de segmentation pour les tableaux des frais : privilégiez des chevauchements légèrement plus importants lorsque Document Intelligence renvoie du texte orienté vers des cellules, et stockez les numéros de page dans les métadonnées Redis afin que les réponses puissent citer « page 1, frais d’ATM » plutôt que des extraits isolés. Ces métadonnées permettent également l’utilisation de filtres hybrides lorsque plusieurs PDF partagent un même index.

    Conseil de segmentation pour les tableaux des frais : privilégiez des chevauchements légèrement plus importants lorsque Document Intelligence renvoie du texte orienté vers des cellules, et stockez les numéros de page dans les métadonnées Redis afin que les réponses puissent citer « page 1, frais d’ATM » plutôt que des extraits isolés. Ces métadonnées permettent également l’utilisation de filtres hybrides lorsque plusieurs PDF partagent un même index.

    Conseil de segmentation pour les tableaux des frais : privilégiez des chevauchements légèrement plus importants lorsque Document Intelligence renvoie du texte orienté vers des cellules, et stockez les numéros de page dans les métadonnées Redis afin que les réponses puissent citer « page 1, frais d’ATM » plutôt que des extraits isolés. Ces métadonnées permettent également l’utilisation de filtres hybrides lorsque plusieurs PDF partagent un même index.

    Conseil de segmentation pour les tableaux des frais : privilégiez des chevauchements légèrement plus importants lorsque Document Intelligence renvoie du texte orienté vers des cellules, et stockez les numéros de page dans les métadonnées Redis afin que les réponses puissent citer « page 1, frais d’ATM » plutôt que des extraits isolés. Ces métadonnées permettent également l’utilisation de filtres hybrides lorsque plusieurs PDF partagent un même index.

    Conseil de segmentation pour les tableaux des frais : privilégiez des chevauchements légèrement plus importants lorsque Document Intelligence renvoie du texte orienté vers des cellules, et stockez les numéros de page dans les métadonnées Redis afin que les réponses puissent citer « page 1, frais d’ATM » plutôt que des extraits isolés. Ces métadonnées permettent également l’utilisation de filtres hybrides lorsque plusieurs PDF partagent un même index.

    Conseil de segmentation pour les tableaux des frais : privilégiez des chevauchements légèrement plus importants lorsque Document Intelligence renvoie du texte orienté vers des cellules, et stockez les numéros de page dans les métadonnées Redis afin que les réponses puissent citer « page 1, frais d’ATM » plutôt que des extraits isolés. Ces métadonnées permettent également l’utilisation de filtres hybrides lorsque plusieurs PDF partagent un même index.

    Conseil de segmentation pour les tableaux des frais : privilégiez des chevauchements légèrement plus importants lorsque Document Intelligence renvoie du texte orienté vers les cellules, et stockez les numéros de page dans les métadonnées Redis afin que les réponses puissent citer « page 1, frais d’ATM » plutôt que des extraits isolés. Ces métadonnées permettent également l’utilisation de filtres hybrides lorsque plusieurs PDF partagent un même index.

    Conseil de segmentation pour les tableaux des frais : privilégiez des chevauchements légèrement plus importants lorsque Document Intelligence renvoie du texte orienté vers les cellules, et stockez les numéros de page dans les métadonnées Redis afin que les réponses puissent citer « page 1, frais d’ATM » plutôt que des extraits isolés. Ces métadonnées permettent également l’utilisation de filtres hybrides lorsque plusieurs PDF partagent un même index.

    Conseil de segmentation pour les tableaux des frais : privilégiez des chevauchements légèrement plus importants lorsque Document Intelligence renvoie du texte orienté cellules, et stockez les numéros de page dans les métadonnées Redis afin que les réponses puissent citer « page 1, frais ATM » plutôt que des extraits isolés. Ces métadonnées permettent également l’utilisation de filtres hybrides lorsque plusieurs PDF partagent un même index.

    Liste de contrôle avant de déclarer que le pipeline est « terminé » : le module Redis est chargé (FT._LIST fonctionne), la dimension d’incorporation correspond au champ de l’index, Document Intelligence renvoie du contenu non vide pour le PDF d’échantillon, et la requête parfaite concernant les frais ATM reste en première position après un redémarrage du conteneur. Automatisez ces quatre vérifications afin que les mises à jour des dépendances ne brisent pas silencieusement la démonstration.

    La liste de contrôle avant de considérer que le pipeline est « terminé » : le module Redis est chargé (FT._LIST fonctionne), la dimension d’incorporation correspond au champ d’index, Document Intelligence renvoie du contenu non vide pour le PDF de exemple, et la requête d’information sur les frais d’ATM reste en première position après un redémarrage froid du conteneur. Automatisez ces quatre vérifications afin que les mises à jour des dépendances ne brisent pas silencieusement la démonstration.