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…
Building a PDF Q&A Pipeline with Azure AI and Redis Vector Search
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:
- Extract text from a PDF using Azure AI Document Intelligence (handles both plain text and tabular data, like fee tables).
- Chunk the extracted text into overlapping, model-sized passages.
- Embed each chunk into a vector using an Azure OpenAI embedding model (
text-embedding-3-small). - 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) andHNSW(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 group —
rg-pdf-ragineastus, the container that holds everything else below. - Document Intelligence resource —
docintel-pdf-rag, anS0-tier Azure Cognitive Services account of kindFormRecognizer. This is the serviceextract_text_nodecalls to pull text/tables out of PDFs. - Azure OpenAI resource —
openai-pdf-rag, anS0-tier Cognitive Services account of kindOpenAI. This is the parent resource that hosts your model deployments. - Embedding model deployment — deploys
text-embedding-3-small(version1) onto theopenai-pdf-ragresource, under the deployment nametext-embedding-3-small, withStandardSKU at 30 capacity units. This is the actual callable endpointembed_and_store_nodeandsearch.pyhit 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:
langchain_community.vectorstores.redis.Redis— deprecated, and broken against currentredis-py(it importsredis.commands.search.indexDefinition, a module path that no longer exists in modernredis-pyreleases).langchain-redis— the suggested replacement, but its dependency pin (langchain-core<0.4) is incompatible with modernlangchain>=1.3.14, producing an unresolvableuvdependency 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 (
float32bytes) 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
dotenvto safely load required database URLs, API keys, and deployment configurations from local.envfiles. - Initializes Embedding Client: Defines
get_embeddings()to create an instance ofAzureOpenAIEmbeddingsmapped 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
SearchIndexviaredisvlusing the schema and Redis URL defined inpersist_vector_graph.py. - Constructs Vector Search Query: Builds a
VectorQueryusingredisvlthat 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
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 andlangchain-redishad 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