Build a RAG system that retrieves and reasons over both text and images from documents
Standard RAG extracts text and misses charts, figures, and tables. Multimodal RAG indexes both text and images, then passes relevant visuals to a vision LLM at query time.
We need an S3 bucket to store extracted images permanently. Reducto’s image URLs expire after 1 hour for security reasons. If we stored those URLs directly in our vector database, they’d be broken by the time someone queries the system tomorrow.S3 gives us permanent, publicly-accessible URLs that work indefinitely.
1
Create an IAM user
Never use your AWS root account for applications. Instead, create a dedicated IAM user with limited permissions.Go to IAM Console → Users → Create user.
2
Attach S3 permissions
Select Attach policies directly, search for AmazonS3FullAccess, check it, and click Create user.This gives the user permission to read and write to S3 buckets, but nothing else in your AWS account.
3
Create access keys
Click on your new user → Security credentials → Create access key.Select “Command Line Interface (CLI)”, confirm, and create.Save both keys now - you won’t see the secret again. You’ll need these for the AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY environment variables.
4
Create an S3 bucket
Go to S3 Console → Create bucket.Choose a unique name (e.g., my-multimodal-rag-images) and select a region close to you for lower latency.
5
Enable public access
By default, S3 buckets are private. We need to make objects publicly readable so your application can fetch images at query time.In your bucket → Permissions tab:First, disable the block public access settings:
Click Block public access → Edit → Uncheck all 4 boxes → Save
Then, add a bucket policy that allows anyone to read objects:
We need a vector index to store text embeddings. Each vector will also include metadata with the S3 URL, so we can fetch the corresponding image at query time.
Dimensions: 1024 (this must match VoyageAI’s voyage-3 model)
Metric: cosine
The dimension setting is critical. VoyageAI’s voyage-3 model outputs 1024-dimensional vectors. If you use a different embedding model, check its output dimensions.
For this cookbook, we’ll use an open-access research paper from PubMed Central about hepatitis B treatment. This paper has 16 pages with multiple figures, charts, and data tables, making it ideal for demonstrating multimodal RAG.Download the sample PDF:
Why chunk_mode: "section"?We use section-based chunking because it keeps figures together with their surrounding explanatory text. If a figure appears in the “Results” section, the chunk will include both the figure and the text that explains it. This improves retrieval quality because the embedding captures the full context.Other options:
page: One chunk per page. Simpler but may split related content.
variable: Adaptive chunking based on content density.
Each chunk contains multiple blocks. A block can be text, a figure, a table, or other content types:
# Look at the first chunkchunk = result.result.chunks[0]print(f"Chunk has {len(chunk.blocks)} blocks")print(f"Embed text preview: {chunk.embed[:200]}...")
// Look at the first chunkconst chunk = result.result.chunks[0];console.log(`Chunk has ${chunk.blocks.length} blocks`);console.log(`Embed text preview: ${chunk.embed.slice(0, 200)}...`);
Chunk has 5 blocksEmbed text preview: # Inhibition of hepatitis B virus via selective apoptosismodulation by Chinese patent medicine Liuweiwuling Tablet## Core TipLiuweiwuling Tablet (LWWL) exhibits a selective pro-apoptotic effect...
# Find the first figurefor chunk in result.result.chunks: for block in chunk.blocks: if block.type == "Figure": print(f"Type: {block.type}") print(f"Page: {block.bbox.page}") print(f"Image URL: {block.image_url[:80]}...") print(f"Content: {block.content[:150]}...") break else: continue break
// Find the first figureouter: for (const chunk of result.result.chunks) { for (const block of chunk.blocks) { if (block.type === "Figure") { console.log(`Type: ${block.type}`); console.log(`Page: ${block.bbox.page}`); console.log(`Image URL: ${block.image_url.slice(0, 80)}...`); console.log(`Content: ${block.content.slice(0, 150)}...`); break outer; } }}
Type: FigurePage: 1Image URL: https://prod-storage20241010144745140900000001.s3.amazonaws.com/6eb2fb19...Content: - Title: World Journal of Gastroenterology- Logo/abbreviation: "W J G" — three white script letters each inside a black square...
Key fields explained:
Field
What it contains
block.type
”Figure”, “Table”, “Text”, etc.
block.bbox.page
Page number (1-indexed)
block.image_url
Temporary URL to the cropped image. Expires in 1 hour.
block.content
AI-generated description of the figure
chunk.embed
Full text optimized for embedding, includes figure descriptions
The content field contains Reducto’s AI-generated description of the figure. This is incredibly useful, as it means your vector search can find figures based on what they show, not just the surrounding text.
S3 URLs include the region. If we get this wrong, the URLs won’t work:
region = s3.get_bucket_location(Bucket=bucket_name).get("LocationConstraint")if region is None: region = "us-east-1" # us-east-1 returns None for LocationConstraintprint(f"Bucket region: {region}")
const locationResponse = await s3.send( new GetBucketLocationCommand({ Bucket: bucketName }));const region = locationResponse.LocationConstraint || "us-east-1";console.log(`Bucket region: ${region}`);
This function downloads an image from Reducto and uploads it to S3:
import requestsdef upload_image_to_s3(image_url, s3_key): """Download image from Reducto and upload to S3.""" # Download from Reducto response = requests.get(image_url) response.raise_for_status() # Upload to S3 s3.put_object( Bucket=bucket_name, Key=s3_key, Body=response.content, ContentType="image/png" ) # Return the permanent S3 URL return f"https://{bucket_name}.s3.{region}.amazonaws.com/{s3_key}"
async function uploadImageToS3(imageUrl, s3Key) { // Download from Reducto const response = await fetch(imageUrl); if (!response.ok) throw new Error(`Failed to fetch: ${response.status}`); const imageBuffer = Buffer.from(await response.arrayBuffer()); // Upload to S3 await s3.send(new PutObjectCommand({ Bucket: bucketName, Key: s3Key, Body: imageBuffer, ContentType: "image/png" })); // Return the permanent S3 URL return `https://${bucketName}.s3.${region}.amazonaws.com/${s3Key}`;}
Why this URL format?S3 URLs follow the pattern https://{bucket}.s3.{region}.amazonaws.com/{key}. Including the region is important, otherwise S3 may redirect requests, which can cause issues with some clients.
Now we loop through all chunks. For each chunk, we check if it contains any figures or tables. If it does, we upload those images to S3 and store the URLs. If it doesn’t, we still index the chunk but without an image URL.This is the key difference from image-only indexing: we index everything, both text-only chunks and chunks with figures.
all_items = []for chunk_idx, chunk in enumerate(result.result.chunks): # Find any figures/tables in this chunk images_in_chunk = [] for block in chunk.blocks: if block.type in ["Figure", "Table"] and block.image_url: image_id = f"chunk-{chunk_idx}-{block.type.lower()}-page{block.bbox.page}" s3_key = f"multimodal-rag/{image_id}.png" s3_url = upload_image_to_s3(block.image_url, s3_key) images_in_chunk.append({ "s3_url": s3_url, "block_type": block.type, "page": block.bbox.page }) # Get the page number from the first block page = chunk.blocks[0].bbox.page if chunk.blocks else 1 # Index this chunk (with or without images) item = { "id": f"chunk-{chunk_idx}", "text": chunk.embed, "page": page, "has_images": len(images_in_chunk) > 0 } # If this chunk has images, include the first one # (for chunks with multiple figures, you could store all URLs) if images_in_chunk: item["image_url"] = images_in_chunk[0]["s3_url"] item["block_type"] = images_in_chunk[0]["block_type"] all_items.append(item)# Count what we havechunks_with_images = sum(1 for item in all_items if item.get("has_images"))chunks_text_only = len(all_items) - chunks_with_imagesprint(f"Total chunks: {len(all_items)}")print(f" - With images: {chunks_with_images}")print(f" - Text only: {chunks_text_only}")
const allItems = [];for (let chunkIdx = 0; chunkIdx < result.result.chunks.length; chunkIdx++) { const chunk = result.result.chunks[chunkIdx]; // Find any figures/tables in this chunk const imagesInChunk = []; for (const block of chunk.blocks) { if (["Figure", "Table"].includes(block.type) && block.image_url) { const imageId = `chunk-${chunkIdx}-${block.type.toLowerCase()}-page${block.bbox.page}`; const s3Key = `multimodal-rag/${imageId}.png`; const s3Url = await uploadImageToS3(block.image_url, s3Key); imagesInChunk.push({ s3_url: s3Url, block_type: block.type, page: block.bbox.page }); } } // Get the page number from the first block const page = chunk.blocks.length > 0 ? chunk.blocks[0].bbox.page : 1; // Index this chunk (with or without images) const item = { id: `chunk-${chunkIdx}`, text: chunk.embed, page: page, has_images: imagesInChunk.length > 0 }; // If this chunk has images, include the first one if (imagesInChunk.length > 0) { item.image_url = imagesInChunk[0].s3_url; item.block_type = imagesInChunk[0].block_type; } allItems.push(item);}// Count what we haveconst chunksWithImages = allItems.filter(item => item.has_images).length;const chunksTextOnly = allItems.length - chunksWithImages;console.log(`Total chunks: ${allItems.length}`);console.log(` - With images: ${chunksWithImages}`);console.log(` - Text only: ${chunksTextOnly}`);
Total chunks: 39 - With images: 33 - Text only: 6
Now our index will contain the entire document. Text-only queries will find relevant text chunks, while queries about figures will find chunks that have associated images.
# Find a chunk that has an imagetest_item = next(item for item in all_items if item.get("image_url"))test_url = test_item["image_url"]print(f"Testing: {test_url}")response = requests.head(test_url)print(f"Status: {response.status_code}")
// Find a chunk that has an imageconst testItem = allItems.find(item => item.image_url);const testUrl = testItem.image_url;console.log(`Testing: ${testUrl}`);const testResponse = await fetch(testUrl, { method: "HEAD" });console.log(`Status: ${testResponse.status}`);
If you get a 403 Forbidden error, your bucket policy isn’t set correctly. Go back to the S3 setup and make sure you’ve enabled public access and added the bucket policy.
import { Pinecone } from "@pinecone-database/pinecone";import { VoyageAIClient } from "voyageai";const pc = new Pinecone({ apiKey: process.env.PINECONE_API_KEY });const index = pc.index("multimodal-rag");const vo = new VoyageAIClient({ apiKey: process.env.VOYAGEAI_API_KEY });
Metadata: Text preview, page number, and optionally an S3 image URL
The vector enables semantic search across the entire document. When a chunk has an associated image, the metadata includes the S3 URL so we can retrieve it at query time.
matches = search("cell viability and treatment effects")for match in matches: print(f"Score: {match.score:.3f}") print(f"Page: {match.metadata['page']}") print(f"Has image: {match.metadata.get('has_images', False)}") if match.metadata.get("image_url"): print(f"Image: {match.metadata['image_url']}") print(f"Text preview: {match.metadata['text'][:100]}...") print("---")
const matches = await search("cell viability and treatment effects");for (const match of matches) { console.log(`Score: ${match.score.toFixed(3)}`); console.log(`Page: ${match.metadata.page}`); console.log(`Has image: ${match.metadata.has_images || false}`); if (match.metadata.image_url) { console.log(`Image: ${match.metadata.image_url}`); } console.log(`Text preview: ${match.metadata.text.slice(0, 100)}...`); console.log("---");}
Score: 0.354Page: 1Has image: TrueImage: https://reducto-multimodal-rag-demo.s3.ap-south-1.amazonaws.com/multimodal-rag/chunk-8-figure-page1.pngText preview: # Inhibition of hepatitis B virus via selective apoptosis modulation by Chinese patent medicine Liuwe...---Score: 0.227Page: 1Has image: TrueImage: https://reducto-multimodal-rag-demo.s3.ap-south-1.amazonaws.com/multimodal-rag/chunk-3-figure-page1.pngText preview: # Inhibition of hepatitis B virus via selective apoptosis modulation by Chinese patent medicine Liuwe...---Score: 0.133Page: 1Has image: FalseText preview: The study investigated the effects of LWWL on hepatocyte apoptosis using flow cytometry analysis...---
The search returns a mix of results. Some chunks have associated images, others are text-only. Both contribute to answering the question.
These Reducto settings can improve your multimodal RAG pipeline:
Figure summaries (enabled by default)
Reducto automatically generates AI descriptions of figures and includes them in the embed field. This is why queries like “show me the revenue chart” can find relevant figures even if “revenue” doesn’t appear in surrounding text.This is controlled by summarize_figures (default: True). Keep it enabled for multimodal RAG.
Agentic figure extraction
For documents with complex charts, enable agentic figure extraction for higher accuracy:
The chunk_mode setting affects how figures relate to surrounding text:
section: Groups content by document sections. Best for structured documents like research papers.
page: One chunk per page. Simple and predictable.
variable: Adaptive chunking based on content density.
For multimodal RAG, section usually works best because it keeps figures with their explanatory text.
Use the embed field for vector search
Always use chunk.embed (not chunk.content) for your vector embeddings. The embed field includes AI-generated figure descriptions that make visual content searchable.
Not every query needs images
Sending images to your LLM adds latency and cost. Consider routing:
Simple factual questions → Text-only RAG
Questions about trends, comparisons, or visuals → Multimodal RAG
You can implement this by checking has_images in retrieved chunks before deciding which path to take.
Handle rate limits gracefully
VoyageAI’s free tier has a 3 requests per minute limit. For production:
Batch multiple texts in a single embedding call
Add delays between calls
Upgrade to a paid plan for higher limits
Limit retrieved images
More images means higher LLM costs and slower responses. For most questions, 2-3 images is sufficient. Use top_k=3 in your search function.