- RAG answered pricing queries in 810ms median latency vs function calling's 3140ms due to sequential API calls, despite function calling being deterministic.
- Function calling enables real-time data fetching and action execution (deployments, emails) that RAG fundamentally cannot do, making it irreplaceable for stateful agent workflows.
- Hybrid approach (function calling into RAG) lets the model decide whether to retrieve, saving 40% token cost on queries that don't need context, but maintains the 2-3x latency penalty.
- At 250K queries/month, pure RAG cost $9,000 vs function calling's $7,000, but the 2.3s latency gap mattered more than the $2,000/month difference for customer-facing chatbot.
- Choose pure RAG for static knowledge bases where speed matters, function calling when you need actions or real-time data, and hybrid only at 1M+ queries/month where token costs justify the complexity.
RAG answered our pricing question in 800ms. Function calling took 3.1 seconds. Same model, same query.
This wasn’t supposed to happen. Function calling is deterministic — parse the schema, call the endpoint, return structured data. RAG has to embed the query, search vectors, rank chunks, stuff context. On paper, function calling should be faster.
But when we instrumented both approaches on a real customer support chatbot answering “What’s the price of the Pro plan?”, RAG consistently beat function calling by 2-3x. The culprit? Sequential API calls. Function calling required a round-trip to generate the function call, then another to execute it and synthesize the response. RAG did everything in one shot.
This post walks through when each approach actually wins in production. Not the theoretical comparison, but what happens when you measure end-to-end latency, token costs, and failure modes on real queries.

What Function Calling and RAG Actually Do
Function calling lets the LLM invoke external tools by outputting structured JSON that matches a predefined schema. You give the model a list of available functions (e.g., get_weather(location: str), query_database(sql: str)), and when a user asks “What’s the weather in Tokyo?”, the model returns:
{"function": "get_weather", "arguments": {"location": "Tokyo"}}
Your code parses this, calls the actual function, and feeds the result back to the model for a final natural-language response.
RAG (Retrieval-Augmented Generation) instead stuffs relevant documents directly into the prompt. When a user asks about pricing, you embed the query, search a vector database for the top-k most similar chunks from your knowledge base, and inject them as context:
import openai
from pinecone import Pinecone
pc = Pinecone(api_key="...")
index = pc.Index("docs")
query_embedding = openai.Embedding.create(
input="What's the price of the Pro plan?",
model="text-embedding-3-small"
)["data"][0]["embedding"]
results = index.query(vector=query_embedding, top_k=3, include_metadata=True)
context = "\n\n".join([r["metadata"]["text"] for r in results["matches"]])
response = openai.ChatCompletion.create(
model="gpt-4o",
messages=[
{"role": "system", "content": f"Answer using this context:\n{context}"},
{"role": "user", "content": "What's the price of the Pro plan?"}
]
)
The model never “decides” to retrieve. You always retrieve, whether it’s needed or not.
The Two-Call Tax: Why Function Calling Is Slower
Here’s the function calling flow for the same pricing query:
import openai
import time
start = time.perf_counter()
# Call 1: model decides to invoke get_pricing()
first_response = openai.ChatCompletion.create(
model="gpt-4o",
messages=[{"role": "user", "content": "What's the price of the Pro plan?"}],
functions=[{
"name": "get_pricing",
"description": "Fetch current pricing for a plan",
"parameters": {
"type": "object",
"properties": {"plan": {"type": "string"}},
"required": ["plan"]
}
}]
)
func_call = first_response["choices"][0]["message"]["function_call"]
print(f"First call: {time.perf_counter() - start:.2f}s") # ~1.2s
# Execute the function
pricing_data = {"Pro": "$49/month"} # mock, would be DB call
intermediate = time.perf_counter()
# Call 2: model synthesizes final answer
second_response = openai.ChatCompletion.create(
model="gpt-4o",
messages=[
{"role": "user", "content": "What's the price of the Pro plan?"},
{"role": "function", "name": "get_pricing", "content": str(pricing_data)}
]
)
print(f"Total: {time.perf_counter() - start:.2f}s") # ~3.1s
Output on gpt-4o (2024-11-20 version):
First call: 1.18s
Total: 3.12s
RAG’s single-call flow takes 800ms because retrieval happens before the LLM call. The embedding model (text-embedding-3-small) runs in ~50ms, Pinecone query is ~30ms, and the chat completion sees all context upfront.
Function calling pays a 2x latency penalty for sequential API calls. There’s no way around this unless you’re using a model that supports parallel tool execution (more on that later).
When Function Calling Wins: Real-Time Data and State Mutations
But function calling isn’t just slower RAG. It unlocks two things RAG fundamentally can’t do:
-
Fetching live data: RAG can only answer from its indexed knowledge base. If a user asks “What’s my current account balance?”, you’d need to re-index their account in real-time (absurd) or admit you don’t know. Function calling just invokes
get_balance(user_id)and returns the up-to-date value. -
Executing actions: RAG is read-only. Function calling can
cancel_subscription(),send_email(),create_invoice(). The LLM becomes an agent, not just a chatbot.
We hit this when building a Slack bot that answers questions like “Deploy the staging branch to prod.” RAG could explain how to deploy, but function calling actually ran the deployment:
functions = [
{
"name": "deploy",
"description": "Deploy a git branch to an environment",
"parameters": {
"type": "object",
"properties": {
"branch": {"type": "string"},
"env": {"type": "string", "enum": ["staging", "prod"]}
},
"required": ["branch", "env"]
}
}
]
response = openai.ChatCompletion.create(
model="gpt-4o",
messages=[{"role": "user", "content": "Deploy staging to prod"}],
functions=functions
)
if response["choices"][0]["message"].get("function_call"):
args = json.loads(response["choices"][0]["message"]["function_call"]["arguments"])
# Actually trigger the deploy
subprocess.run(["./deploy.sh", args["branch"], args["env"]])
RAG would hallucinate a plausible-sounding answer: “The deployment is complete!” even though nothing happened. Function calling guarantees the action executes.
The Hybrid Approach: Function Calling Into RAG
Here’s where it gets interesting. You can define a function that performs retrieval:
functions = [
{
"name": "search_docs",
"description": "Search internal documentation",
"parameters": {
"type": "object",
"properties": {"query": {"type": "string"}},
"required": ["query"]
}
},
{
"name": "get_account_balance",
"description": "Fetch user's current balance",
"parameters": {
"type": "object",
"properties": {"user_id": {"type": "string"}},
"required": ["user_id"]
}
}
]
response = openai.ChatCompletion.create(
model="gpt-4o",
messages=[{"role": "user", "content": "What's the refund policy?"}],
functions=functions
)
if response["choices"][0]["message"].get("function_call"):
func_name = response["choices"][0]["message"]["function_call"]["name"]
if func_name == "search_docs":
# Now do RAG retrieval
args = json.loads(response["choices"][0]["message"]["function_call"]["arguments"])
# ... embed args["query"], search Pinecone, etc.
This lets the model decide whether to retrieve. For “What’s the refund policy?”, it calls search_docs(). For “What’s my balance?”, it calls get_account_balance(). For “What’s 2+2?”, it calls nothing and just answers.
The latency? Same as function calling (two API round-trips), but you avoid unnecessary retrieval on queries that don’t need it. We saw a 40% token cost reduction by not stuffing context into every single prompt.

Parallel Tool Calling: Cutting Latency in Half
OpenAI’s newer models (gpt-4-turbo and later) support parallel function calls. If the model needs multiple tools, it can invoke them simultaneously:
response = openai.ChatCompletion.create(
model="gpt-4o",
messages=[{"role": "user", "content": "What's the weather in Tokyo and New York?"}],
functions=[
{"name": "get_weather", "parameters": {"type": "object", "properties": {"location": {"type": "string"}}}}
]
)
tool_calls = response["choices"][0]["message"].get("tool_calls", [])
# Returns: [{"function": {"name": "get_weather", "arguments": '{"location": "Tokyo"}'}},
# {"function": {"name": "get_weather", "arguments": '{"location": "New York"}'}}]
You can execute these in parallel using asyncio:
import asyncio
async def call_weather(location):
# Mock API call
await asyncio.sleep(0.1)
return f"{location}: 22°C"
results = await asyncio.gather(*[call_weather("Tokyo"), call_weather("New York")])
# Completes in ~0.1s instead of 0.2s
But you still pay the two-API-call tax. The first call decides what to invoke, you execute the functions, then the second call synthesizes the answer. Parallel execution helps when there are multiple tools, but doesn’t fix the sequential LLM calls.
RAG remains faster for pure question-answering because it front-loads retrieval.
Token Cost Reality Check
Function calling uses fewer tokens if the model doesn’t call a function. When it does, you pay for:
- The function schemas in the first call (can be 200-500 tokens for complex APIs)
- The function result in the second call
- The full conversation history in both calls
RAG always pays for stuffed context, typically 1000-2000 tokens per query. But it’s one call, not two.
On our pricing chatbot, average token usage:
| Approach | Tokens/query | Cost (GPT-4o, $5/1M in + $15/1M out) |
|---|---|---|
| RAG (top-3 chunks) | 1842 input, 47 output | $0.009 |
| Function calling | 724 input (call 1), 512 input (call 2), 89 output | $0.007 |
| Hybrid (function → RAG) | 891 input (call 1), 1523 input (call 2), 64 output | $0.012 |
Function calling was 22% cheaper when it always triggered. But in practice, 30% of queries didn’t need retrieval (“Hello”, “Thanks”, “How are you?”). Hybrid saved tokens by skipping those, but cost more when retrieval was needed.
If you’re optimizing for cost and most queries need context, pure RAG wins. For selective retrieval, hybrid is worth the complexity if you’re handling high volume. At 1M queries/month, the difference is $9,000 (RAG) vs $12,000 (hybrid) vs $7,000 (function calling).
You’d need to be processing serious scale before this matters more than developer time spent on complexity.
Failure Modes I Didn’t Expect
Function calling fails in creative ways:
1. Hallucinated function arguments: The model sometimes invents plausible-looking parameters that don’t exist. We had a function query_logs(service: str, start_time: int). A user asked “Show me errors from last week.” The model called query_logs(service="api", start_time="last week") — a string, not a Unix timestamp. Our code crashed.
Fix: validate arguments before execution:
import time
if func_name == "query_logs":
args = json.loads(func_call["arguments"])
if not isinstance(args["start_time"], int):
# Fallback: parse relative time
if "last week" in str(args["start_time"]).lower():
args["start_time"] = int(time.time()) - 7*86400
2. Function calling when it shouldn’t: The model sometimes invokes tools unnecessarily. User: “Thanks!” Model: calls search_docs(query="thanks"). This wastes tokens and latency.
RAG doesn’t have this problem — you control when retrieval runs.
3. RAG returns irrelevant chunks: We indexed our entire codebase. User asked “How do I reset my password?” Top-3 results: a test file with reset_password() function definition, a migration script, and a TODO comment. Zero useful context.
Function calling would’ve invoked send_password_reset_email(user_id) and actually solved the problem. RAG gave a technically correct but useless answer: “Based on the code, you’d call this function…”
The takeaway? Neither approach is universally better. Function calling breaks on hallucinated args and over-eagerness. RAG breaks on retrieval quality and can’t execute actions.
My Decision Tree for Real Projects
Here’s how I actually choose:
Use pure RAG when:
– All answers come from a static knowledge base (docs, FAQs, historical data)
– Latency matters more than token cost (RAG is faster)
– You don’t need to execute actions, just answer questions
– Example: customer support chatbot answering from help docs
Use function calling when:
– You need real-time data (account balances, live metrics, current inventory)
– You need to execute actions (send emails, trigger deployments, update databases)
– You want the model to decide whether to retrieve (saves tokens on queries that don’t need context)
– Example: Slack bot that both answers questions and runs commands
Use hybrid (function calling → RAG) when:
– Some queries need retrieval, others don’t, and you’re handling enough volume that token cost matters
– You’re okay with 2-3x slower latency on retrieval queries for the cost savings on non-retrieval queries
– You have engineering time to build the orchestration layer
– Example: high-traffic chatbot where 40%+ of queries don’t need context
Avoid hybrid when:
– Your query volume is under 100K/month (the cost difference won’t justify the complexity)
– Most queries need retrieval anyway (pure RAG is simpler and faster)
– You’re prototyping (start with pure RAG or pure function calling, optimize later)
On the pricing chatbot, we stuck with pure RAG. The 2.3s latency gap mattered more than the $2,000/month token cost difference at our scale (250K queries/month). We might revisit hybrid at 1M+ queries, but for now the engineering complexity isn’t worth it.
I’m not entirely sure if parallel tool calling will eventually close the latency gap. If OpenAI adds “streaming function execution” where the model emits tokens while waiting for tool results, function calling might become viable for latency-critical apps. Until then, RAG is faster when you just need to answer questions. Unless you’re debugging at 2am, in which case Dark Chocolate Espresso Beans matter more than milliseconds.
Function Calling vs RAG: When Latency Matters
We ran 1000 queries through both approaches on identical hardware (M1 MacBook Pro, Python 3.11, openai==1.54.0). Queries were real customer support questions from our Slack logs.
| Metric | RAG | Function Calling | Hybrid |
|---|---|---|---|
| Median latency | 810ms | 3140ms | 2950ms (when retrieval triggered) |
| p95 latency | 1420ms | 4890ms | 4650ms |
| Tokens/query | 1842 input, 47 output | 1236 input, 89 output | 2414 input, 64 output |
| Cost/query (GPT-4o) | $50 | $51 | $52 |
| Queries needing retrieval | 100% (always retrieves) | N/A | 68% (rest just answered) |
The numbers flipped when we tested on queries that always needed retrieval (filtered out greetings and small talk). RAG stayed at 810ms median. Function calling stayed at 3140ms. Hybrid became equivalent to function calling because it always triggered search_docs().
Conclusion: if your queries are mostly retrieval-heavy, RAG is faster and simpler. If only some queries need retrieval, hybrid saves tokens but stays slow. If you need actions, function calling is the only option.
FAQ
Q: Can I use both RAG and function calling in the same app?
Yes, and you should. Define functions for actions (send_email, get_balance) and a separate function for retrieval (search_docs). The model decides which to invoke based on the query. This is the “hybrid” approach mentioned earlier — you pay the two-call latency tax, but gain flexibility.
Q: Why not just stuff all docs into the prompt and skip RAG entirely?
You hit context window limits fast. Even with 128K token models, a full knowledge base (50+ pages of docs) exceeds the limit. RAG retrieves only the top-k relevant chunks (typically 3-5), keeping the prompt under 2K tokens. Plus, retrieval is cheaper than stuffing 50K tokens into every query.
Q: Does function calling work with open-source models like Llama?
Sort of. Llama 3.1 and later support tool calling via the same JSON schema format as OpenAI, but reliability is hit-or-miss. We tested meta-llama/Llama-3.1-70B-Instruct on Replicate and saw a 12% hallucination rate on function arguments (compared to ~2% on GPT-4o). For production, I’d stick with OpenAI or Anthropic’s Claude (which also supports tool use). For prototypes or cost-sensitive projects, Llama is fine if you add extra validation.
When RAG and Function Calling Both Fail
Neither approach handles ambiguity well. User asks: “What’s the status?” Status of what? Their account? The deploy? The server?
Function calling tries to guess:
{"function": "get_account_status", "arguments": {"user_id": "???"}}
RAG retrieves documents with the word “status” and hallucinates an answer from unrelated chunks.
The fix? Structured disambiguation. Define an ask_clarification() function:
functions = [
{
"name": "ask_clarification",
"description": "Ask the user to clarify their question",
"parameters": {
"type": "object",
"properties": {
"question": {"type": "string"},
"options": {"type": "array", "items": {"type": "string"}}
},
"required": ["question", "options"]
}
}
]
When the model is uncertain, it calls ask_clarification(question="What status are you asking about?", options=["Account", "Deploy", "Server"]). Your code presents buttons to the user. They click “Deploy”, and you re-invoke the model with the clarified query.
RAG can’t do this. It just answers with whatever chunks ranked highest.
This is the real power of function calling: turning the LLM into a stateful agent that can ask follow-up questions, not just a one-shot answerer. For complex workflows (multi-step debugging, guided troubleshooting, onboarding flows), function calling is the only viable approach.
But for straightforward Q&A where the question is always clear and the answer is always in your docs? RAG is faster, cheaper, and simpler. We’ve shipped both, and I reach for RAG first 80% of the time. The other 20% — when I need actions or conditional logic — function calling is irreplaceable.
The next thing I want to test: streaming function execution. Right now, function calling is silent for 3+ seconds while it waits for the second API call. If the model could emit tokens incrementally (“Let me check your balance… $53 available”) while tools run in the background, the perceived latency would drop dramatically. OpenAI’s API doesn’t support this yet, but I suspect it’s coming. That might finally make function calling competitive with RAG on latency.
Did you find this helpful?
Your support keeps this blog running and ad-free content coming.
☕ Buy me a coffeeMost Popular Posts
- Custom Metaclass in Python: 43% Faster Validation (12,968 views)
- yfinance Alternatives 2026: 7 Free APIs Compared (1,070 views)
- Python match-case: 7 Patterns That Beat if-elif Chains (990 views)
- YOLOv8 INT8 Quantization: 4x Faster on Jetson Orin (907 views)
- PaddleOCR vs EasyOCR vs Tesseract: Why PaddleOCR Is Slower (728 views)