← Back to list

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

Organizations sit on huge volumes of unstructured content — PDFs like fee schedules, disclosures, contracts, and policy documents — that…

Krishnan Sriram · 2026-08-03 02:51 · 1 claps · 11.4 min read
#langgraph #redis-search #agentic-rag #azure-doc-intelligence #azureopenai
Open on Medium ↗
Wiki topics: LLM · Large Language Models RAG · RAG & Retrieval AGT · AI Agents ☁️ · DevOps & Cloud

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

Photo by Sasun Bughdaryan on Unsplash

Photo by Sasun Bughdaryan on Unsplash

Organizations sit on huge volumes of unstructured content — PDFs like fee schedules, disclosures, contracts, and policy documents — that are hard to search with keyword matching alone. This project builds a small, self-contained pipeline that makes a PDF’s content semantically searchable: given a natural language question, it returns the most relevant passages from the document, even if the wording doesn’t match exactly.

The pipeline does four things, orchestrated as a LangGraph state graph:

  1. Extract text from a PDF using Azure AI Document Intelligence (handles both plain text and tabular data, like fee tables).
  2. Chunk the extracted text into overlapping, model-sized passages.
  3. Embed each chunk into a vector using an Azure OpenAI embedding model (text-embedding-3-small).
  4. Store and search those vectors in a local Redis instance (Feel free to replace it with a remote Redis instance if yoiu have one), using Redis’s native vector search capability.

The goal was to keep this deliberately simple: a handful of Python files, no framework overhead beyond what’s needed, and infrastructure that runs entirely on a laptop (aside from the two Azure AI calls).

We tested the full pipeline end-to-end against a real, one-page PDF pulled directly from U.S. Bank’s own site — their Expense Card fee schedule — and confirmed that a natural-language question like “what is the ATM withdrawal fee out of network?” correctly retrieves the exact passage containing the $2.50 answer, ranked first by similarity.

Why Redis as the Vector Store

Redis is best known as an in-memory key-value store, but the RediSearch module (bundled in redis-stack) adds full vector indexing and similarity search on top of it. That combination is what makes it a credible vector database, not just a cache.

Why it’s a reasonable choice here:

  • Speed — being in-memory, Redis vector search is extremely low-latency, which matters for interactive, conversational retrieval.
  • One system, two jobs — many applications already use Redis for caching or session storage; adding vector search means one less piece of infrastructure to run and operate.
  • Flexible indexing — supports both FLAT (brute-force, exact) and HNSW (approximate nearest neighbor) indexing, so it scales from a quick prototype up to millions of vectors.
  • Hybrid search — vector similarity can be combined with traditional filters (tags, numeric ranges, full-text) in a single query, which is useful for filtering by document source, date, or category alongside semantic relevance.
  • Runs anywhere — from a laptop container (as we did here) to managed cloud offerings, with the same query semantics.

Redis Search alternatives

Prerequisites

  • Docker (for local Redis)
  • Azure CLI, logged in (az login)
  • An Azure subscription with Azure OpenAI access approved
  • Python 3.12 (see the “Python version” note below — 3.14 caused build failures with this dependency stack)
  • [uv](https://docs.astral.sh/uv/) for Python package/environment management

Provision Azure resources

  • Resource grouprg-pdf-rag in eastus, the container that holds everything else below.
  • Document Intelligence resourcedocintel-pdf-rag, an S0-tier Azure Cognitive Services account of kind FormRecognizer. This is the service extract_text_node calls to pull text/tables out of PDFs.
  • Azure OpenAI resourceopenai-pdf-rag, an S0-tier Cognitive Services account of kind OpenAI. This is the parent resource that hosts your model deployments.
  • Embedding model deployment — deploys text-embedding-3-small (version 1) onto the openai-pdf-rag resource, under the deployment name text-embedding-3-small, with Standard SKU at 30 capacity units. This is the actual callable endpoint embed_and_store_node and search.py hit to turn text into vectors.

Please check the script in here

Start Redis locally

redis-stack bundles RediSearch (the vector-search module) plus RedisInsight, a web UI for browsing the index [docker-compose.yml](https://github.com/KrishnanSriram/chat-file-search-inmemory/blob/main/docker-compose.yml)

docker compose up -d

RedisInsight (GUI for browsing keys/vectors) was then available at [http://localhost:8001](http://localhost:8001.)

Python environment setup (with uv)

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 .

Issue: hardcoded requires-python = ">=3.14"

The project’s pyproject.toml had pinned requires-python = ">=3.14". Python 3.14 is new enough that several C-extension dependencies (notably ml-dtypes, pulled in by redisvl) don't yet ship prebuilt wheels for it, so uv tried to compile from source and failed with a missing Python.h header. Rather than installing build toolchains to compile everything from source, we standardized on Python 3.12, which has full wheel coverage across this stack.

Fix:

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

Output: confirmed Python 3.12.x in the rebuilt virtual environment.

Issue: deprecated / incompatible LangChain Redis integrations

Two dead ends were hit before settling on the final approach:

  1. langchain_community.vectorstores.redis.Redis — deprecated, and broken against current redis-py (it imports redis.commands.search.indexDefinition, a module path that no longer exists in modern redis-py releases).
  2. langchain-redis — the suggested replacement, but its dependency pin (langchain-core<0.4) is incompatible with modern langchain>=1.3.14, producing an unresolvable uv dependency conflict.

Final approach: bypass LangChain’s vector-store abstraction entirely and use **redisvl** (Redis's own client library) directly. It has no langchain-core dependency, so nothing conflicts.

Final install command

uv add langgraph langchain langchain-openai langchain-text-splitters \
       azure-ai-documentintelligence redis redisvl numpy python-dotenv

Stepup Environment variables

.env (values redacted; keys were rotated after testing):

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

Actual code to vectorize data [persist_vector_graph.py](https://github.com/KrishnanSriram/chat-file-search-inmemory/blob/main/persist_vector_graph.py)

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}'")

Here’s what we do

Here is a simple summary of what the code does, broken down step-by-step:

  • Sets Up Configuration: Loads environment variables (like API keys and service endpoints) needed to connect to Azure and Redis.
  • Defines Database Schema: Sets up the index layout for Redis, specifying fields for content, file source, chunk order, and a 1,536-dimensional vector for embeddings.
  • Helper Functions: Converts vector embeddings into binary format (float32 bytes) so Redis can efficiently store and search them.
  • Defines Graph State: Tracks the data passing through the pipeline, including the PDF file path, extracted text, text chunks, and stored count.
  • Step 1 — Extracts Text: Uses Azure AI Document Intelligence (prebuilt-layout) to read the input PDF and pull out all of its text content.
  • Step 2 — Chunks the Text: Splits the extracted text into smaller, overlapping sections (1,000 characters each with a 150-character overlap) using LangChain.
  • Step 3 — Generates Embeddings: Converts each text chunk into a numerical vector representation using Azure OpenAI’s embedding model (text-embedding-3-small).
  • Step 4 — Stores in Redis: Creates the search index if it doesn’t already exist and saves the text chunks alongside their vector embeddings into Redis via redisvl.
  • Builds the Workflow: Links the extraction, chunking, and embedding steps together into a sequential pipeline using a LangGraph StateGraph.
  • Executes the Pipeline: Takes a PDF path from the command line, runs it through the full workflow, and prints the total text length, number of chunks, and vectors saved.

The sample document

We tested against a real, single-page PDF pulled directly from U.S. Bank’s own site — their Expense Card cash-access fee schedule:

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"

This document mixes plain text (usage tips) with structured fee tables and transaction-limit tables — a good stress test for extraction quality.

Running the pipeline (with actual results)

Ingest the PDF

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'

We can check the content of this file with [inspect_redis.py](https://github.com/KrishnanSriram/chat-file-search-inmemory/blob/main/inspect_redis.py) You can check this code here

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

All 7 chunks were confirmed present, correctly ordered, correctly attributed to the source file, and with the fee table data extracted cleanly (dollar amounts and their associated line items stayed together). Now that we have both visual confirmation from UI and command, we can execute our query against the vector store for results

Run a semantic search

Let’s build a simple [search.py](https://github.com/KrishnanSriram/chat-file-search-inmemory/blob/main/search.py) code to search on vector store. This is the code we need

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

Here’s what this code does

  • Loads Environment Variables: Uses dotenv to safely load required database URLs, API keys, and deployment configurations from local .env files.
  • Initializes Embedding Client: Defines get_embeddings() to create an instance of AzureOpenAIEmbeddings mapped to your specific Azure OpenAI deployment.
  • Captures CLI User Input: Parses input from the command line (sys.argv), combining arguments into a single string to treat as the natural language search query.
  • Converts Query to Vector: Generates a 1,536-dimensional vector embedding for the input query string using embeddings.embed_query().
  • Connects to Search Index: Initializes a SearchIndex via redisvl using the schema and Redis URL defined in persist_vector_graph.py.
  • Constructs Vector Search Query: Builds a VectorQuery using redisvl that converts the query vector to byte format, specifies the target vector field (embedding), requests metadata attributes (content, source, chunk_index), and sets a top-K limit of 4 matches.
  • Executes Nearest Neighbor Search: Runs index.query(vq) against Redis to execute a similarity search and return the top 4 most semantically relevant text chunks.
  • Outputs Results: Iterates through the search results, printing the similarity score (vector_distance), source file name, chunk index, and the first 500 characters of the matched text chunk.

Let’s take it for a spin

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

Photo by Vitaly Gariev on Unsplash

Photo by Vitaly Gariev on Unsplash

Lessons learned

Key lessons learned along the way, worth remembering for future setups:

  • Pin your Python version deliberately. Bleeding-edge releases (3.14 at the time of this project) often lag behind in prebuilt-wheel support for C-extension-heavy packages like numpy/ml-dtypes.
  • Watch for abandoned LangChain integration packages. Both langchain_community's Redis integration and langchain-redis had version-compatibility issues with current LangChain releases; going directly to the underlying client library (redisvl) avoided the problem entirely and is arguably a more durable choice regardless.
  • Be careful mixing binary and text fields in Redis clients configured with decode_responses=True. Fetch only the fields you need when a hash contains raw bytes (like a stored embedding).

What’s Next?

Bring-your-own-vectors — We compute custom embeddings (like we did now), then push pre-computed chunks + vectors into the index via the API. Azure AI Search just stores and searches them; it never calls the embedding model.

Until then stay safe and keep reading


메타데이터
post_id
1bf6ff6746cd
slug
building-a-pdf-q-a-pipeline-with-azure-ai-and-redis-vector-search-1bf6ff6746cd
url
https://medium.com/@krishnan.srm/building-a-pdf-q-a-pipeline-with-azure-ai-and-redis-vector-search-1bf6ff6746cd
canonical_url
https://medium.com/@krishnan.srm/building-a-pdf-q-a-pipeline-with-azure-ai-and-redis-vector-search-1bf6ff6746cd
author_url
https://medium.com/@krishnan.srm
status
ok
fetched_at
2026-08-29 20:19:46