Tool Use and RAG in Production — A Complete Beginner's Study Guide A developer published a beginner's study guide explaining tool use and retrieval-augmented generation (RAG) for large language models in production. The guide uses real-world analogies and runnable code to demonstrate how models delegate external tasks like weather lookups to deterministic functions. It emphasizes that the model only reasons and constructs arguments, while the application executes the actual function calls. This guide assumes you know nothing about LLMs beyond "I can chat with ChatGPT/Claude." Every concept is introduced with a real-world analogy first, then explained technically, then backed by runnable code. Imagine you ask a friend, "Hey, what's the weather in Tokyo right now?" Your friend doesn't know this off the top of their head — nobody just knows live weather. So your friend pulls out their phone, opens a weather app, types "Tokyo," reads the result, and tells you: "It's 24°C and cloudy." Notice what happened: your friend the "brain" doing the reasoning never became a weather sensor. They recognized they needed external, live information, used a tool the weather app to fetch it, and then used that fetched information to answer you in natural language. This is exactly what "tool use" also called "function calling" means for an LLM like Claude or GPT. The model is the friend. It's great at reasoning and language, but it has no live connection to today's weather, your company's database, or a calculator that never makes arithmetic mistakes. So we give it a list of "apps" tools it's allowed to ask for, and something else — your application code — actually goes and uses those apps on its behalf. Tool use extends a model's capabilities to call "external systems" — anything outside the model's own weights and training data. Examples from the notes: These are all things a language model is fundamentally bad at doing internally it can't refresh live weather data, and it's notoriously unreliable at exact arithmetic , so instead of asking the model to guess, we let it delegate to a real, deterministic function. How it's wired up: get weather with {"city": "Tokyo"} ." The single most important sentence in this whole section, straight from the notes: "The model never calls a function itself " The model's role is reasoning and argument construction only . It cannot reach out to the internet, touch your database, or execute code. Everything it does is emit text/JSON. Your application is the one with hands. The full lifecycle the loop , step by step: get weather "Tokyo" for real — hits a weather API, database, calculator, whatever the tool represents.Along the way your application also has to handle practical realities like rate limits on the tools you're calling e.g., a weather API only allows so many requests per minute — that's your application's job too, not the model's. Below is a minimal, close-to-runnable example using the Anthropic SDK style referenced in the notes. It defines a get weather tool, sends a prompt, receives a tool use block, executes a mock function standing in for a real weather API , and sends the result back so the model can finish its answer. python pip install anthropic --break-system-packages import anthropic import json Step 0: create a client assumes ANTHROPIC API KEY is set as an environment variable client = anthropic.Anthropic --------------------------------------------------------------------------- Step 1: Define the tool as "a function + metadata" This is NOT the real function. It's a JSON description that tells the model "this function exists, here's its name, what it does, and what arguments it needs." --------------------------------------------------------------------------- tools = { "name": "get weather", "description": "Get the current weather for a given city.", "input schema": { "type": "object", "properties": { "city": {"type": "string", "description": "City name, e.g. Tokyo"}, "unit": { "type": "string", "enum": "celsius", "fahrenheit" , "description": "Temperature unit to return", }, }, "required": "city" , unit is optional }, } --------------------------------------------------------------------------- Step 2: This is the REAL function that will actually run. In production this would call a weather API e.g. OpenWeatherMap . Here we mock it so the example is runnable without external dependencies. --------------------------------------------------------------------------- def get weather city: str, unit: str = "celsius" - dict: """Pretend weather lookup — replace with a real HTTP call in production.""" fake database = {"Tokyo": 24, "London": 14, "Delhi": 33} temp c = fake database.get city, 20 temp = temp c if unit == "celsius" else temp c 9 / 5 + 32 return {"city": city, "temperature": temp, "unit": unit} --------------------------------------------------------------------------- Step 3: Send the user's question + the tool list to the model. The model will read the question and DECIDE if it needs the tool. --------------------------------------------------------------------------- messages = {"role": "user", "content": "What is the weather in Tokyo?"} response = client.messages.create model="claude-sonnet-4-6", max tokens=1024, tools=tools, messages=messages, --------------------------------------------------------------------------- Step 4: "Application intercepts the response." We look through the model's response blocks. If it contains a "tool use" block, that means the model wants us to run a function on its behalf. The model itself did NOT run anything — it only asked. --------------------------------------------------------------------------- tool use block = None for block in response.content: if block.type == "tool use": tool use block = block break if tool use block: print f"Model wants to call: {tool use block.name} with {tool use block.input}" Step 5: "Application invokes the function." We actually run the real code. if tool use block.name == "get weather": result = get weather tool use block.input else: result = {"error": "unknown tool"} Step 6: "Application sends the result back to model" as a tool result message. We must echo back the ORIGINAL assistant message containing the tool use plus a new "user" message containing the tool result, so the model has full context of what it asked for and what it got back. messages.append {"role": "assistant", "content": response.content} messages.append { "role": "user", "content": { "type": "tool result", "tool use id": tool use block.id, "content": json.dumps result , } , } Step 7: "Model proceeds..." — call the model again so it can turn the raw tool result into a natural-language final answer. final response = client.messages.create model="claude-sonnet-4-6", max tokens=1024, tools=tools, messages=messages, for block in final response.content: if block.type == "text": print "Final answer:", block.text else: The model answered directly without needing a tool. for block in response.content: if block.type == "text": print "Final answer:", block.text Notice the loop shape: model decides → app intercepts → app invokes → app returns result → model proceeds. That five-step handoff is the entire mental model for tool use, and it never changes no matter how complex the tool is. Imagine you're hiring a new employee and you hand them a one-line job description: "Search for products." That's it — no other instructions. On day one, a customer asks them "how much does this cost?" and the new employee, trying to be helpful, uses the "search for products" tool because it's the only tool they were told about, even though there's actually a dedicated pricing tool sitting right next to it that they didn't know they should prefer. They also aren't told whether "search" means searching by name, by category, or by a product code — so they guess, and sometimes guess wrong. Now imagine instead you hand them a proper SOP: "Search for products by name, category, or SKU. Use this only when the customer is explicitly trying to find or filter catalog items. Do NOT use this for pricing questions or stock checks — those have their own dedicated tools." Now the employee has almost no room to misinterpret their job. This is the difference between a bad tool schema and a good one. The LLM is that new employee, and the schema is the only "training" it gets before being thrown into the job. From the notes: "JSON schema is the contract between your intent and the model's behaviour." And critically: "Ambiguous schema is the most common root cause of tool failure and is 'invisible' until production." It's invisible because in your own testing you tend to ask clean, obvious questions — production users ask messy, ambiguous, unexpected ones, and that's where a vague schema quietly breaks. There are three rules for designing a good tool schema: Rule 1 — Specify semantics, not just a description. Don't just say what the function is named or roughly does. Say when to use it , when not to use it , and what it should never be confused with . The notes' worked example: "search for products" "Search for products by name, category, or SKU. Use only when user is explicitly looking to find or filter catalog items. Do not use for pricing questions or stock checks — those have other dedicated tools." Rule 2 — Be deliberate about required vs. optional parameters. If a parameter is optional and you don't clearly explain what happens when it's omitted, the model may hallucinate a value for it just to fill the slot marked with a red ✗ in the notes . Conversely, if a parameter is marked mandatory but the model doesn't have a value for it, it may pass nothing at all or an invalid placeholder also marked ✗ . The fix from the notes: provide default values for the model to use so there's no ambiguity about what an "empty" optional parameter should look like. Rule 3 — Enums are the cheapest reliability primitive you have. Whenever a parameter has a fixed, known set of valid values a category, a unit, a status , constrain it with an enum instead of leaving it as a free-form string. This "keeps things readable and reduces hallucination" — the model can only pick from the list you gave it, it can't invent "CategoryXYZ" out of thin air. The "too many tools" problem, and its fix — dynamic tool loading. The notes flag: "large number of tools: model gets confused" — when a model is handed 50+ tools at once, two hard questions creep in: "What category tool?" and "Which specific tool?" The model starts guessing between similar-looking tools. The fix is dynamic tool loading : instead of always sending the model your entire tool catalog, your application first figures out from context, intent classification, or the conversation's domain which subset of tools is actually relevant, and only sends that subset to the model for this particular turn. Fewer, more relevant choices means fewer wrong guesses. ============================================================================= BAD SCHEMA Vague description, no enum for category, ambiguous optional parameter. ============================================================================= bad tool = { "name": "search products", "description": "Search for products", <-- too vague: search how? for what purpose? "input schema": { "type": "object", "properties": { "query": {"type": "string"}, "category": {"type": "string"}, <-- free text: model may invent categories "include out of stock": {"type": "boolean"}, <-- optional, no default explained }, "required": "query" , }, } Problems this causes in production: 1. Model may call this tool for pricing questions, since nothing tells it not to. 2. Model may pass category="Electronics & Gadgets" when your real categories are "electronics", "apparel", "home" - zero results, silent failure. 3. Model may omit include out of stock, or guess True/False randomly, since there's no guidance on what happens if it's left out. ============================================================================= GOOD SCHEMA Specifies semantics when to use / not use , enum for category, explicit default behaviour for optional params. ============================================================================= good tool = { "name": "search products", "description": "Search for products by name, category, or SKU. " "Use only when the user is explicitly looking to find or filter catalog " "items. Do NOT use this for pricing questions or stock-level checks — " "those have their own dedicated tools get price, check stock ." , "input schema": { "type": "object", "properties": { "query": { "type": "string", "description": "Free-text search term, e.g. a product name or SKU.", }, "category": { "type": "string", Enum = the model can ONLY pick from these exact values. "enum": "electronics", "apparel", "home", "books", "toys" , "description": "Optional. Restrict results to one catalog category.", }, "include out of stock": { "type": "boolean", "description": "Whether to include out-of-stock items. " "If omitted, DEFAULTS to false only show in-stock items ." , }, }, "required": "query" , }, } The good schema tells the model: what it's for, what it's NOT for, exactly which category strings are valid, and exactly what "not specifying" means. That triple clarity is what the notes call the "contract" between your intent and the model's behaviour. A tiny illustration of picking a relevant SUBSET of tools before calling the model, instead of always sending your entire tool catalog. ALL TOOLS = { "search products": {...}, schema dicts omitted for brevity "get price": {...}, "check stock": {...}, "get weather": {...}, "run bash command": {...}, "search hr docs": {...}, } A simple keyword-based intent router. In production this is often a small classifier model or an embedding-similarity lookup, not just keywords — but the principle narrow the toolset BEFORE calling the main model is identical. def select relevant tools user message: str - list str : msg = user message.lower if any word in msg for word in "price", "cost", "how much" : return "get price" if any word in msg for word in "stock", "available", "in stock" : return "check stock" if any word in msg for word in "find", "search", "looking for" : return "search products" if "weather" in msg: return "get weather" Fallback: give a small default set rather than everything return "search products", "get price" def build tools for request user message: str - list dict : relevant names = select relevant tools user message return ALL TOOLS name for name in relevant names if name in ALL TOOLS Usage: only the relevant tools go into the API call, not all 6+. tools for this call = build tools for request "How much does the red mug cost?" - only sends the get price tool, so the model can't confuse it with search products. Suppose you ask a travel-planning friend: "Can you check the weather in Paris, Tokyo, and New York for me?" A slow friend would check Paris, wait, tell you, then check Tokyo, wait, tell you, then check New York. A smart friend instead opens three browser tabs at once, checks all three simultaneously, and gives you all three answers together. Same total work, far less waiting. Now imagine one of those three lookups fails — say the New York weather site is down. Does your friend refuse to tell you about Paris and Tokyo just because New York failed? Or do they hand you what they do have, and mention New York didn't work? That's a judgment call — and it's the same judgment call your application has to make about tool calls. Parallel tool calls: the notes describe a model that can emit multiple tool-call requests in a single turn — e.g., "compare weather across N cities" or a "trip plan" that needs both search flight and search hotel at once. Your orchestration layer the application code, not the model is responsible for "fanning out" these calls concurrently , collecting all the results, and sending them back together. The direct payoff: it improves overall time to completion — you pay for the slowest of the N calls, not the sum of all N. The notes' guidance: use this whenever the operations are independent of one another one call's result doesn't depend on another's . Failure handling — "it depends". The notes are refreshingly honest here: "Answer to any question could well be IT DEPENDS." There isn't one universal right answer; it's a judgment call based on your product's tolerance for incompleteness vs. correctness. Three named strategies: The notes give a concrete example of when parallel fan-out matters: "4 websearch calls, lookup across multiple knowledge bases." If a question needs facts from 4 different knowledge bases, firing all 4 lookups concurrently instead of one after another is the entire difference between a fast, responsive system and a sluggish one. python import asyncio import random --------------------------------------------------------------------------- Mock tool: fetch weather for one city. Sometimes fails, to demonstrate failure handling simulating a flaky downstream API . --------------------------------------------------------------------------- async def fetch weather city: str - dict: await asyncio.sleep random.uniform 0.1, 0.5 simulate network latency if city == "New York" and random.random < 0.5: raise ConnectionError f"Weather API timed out for {city}" return {"city": city, "temp c": random.randint 10, 35 } ============================================================================= STRATEGY 1: PREVENT Validate/guard BEFORE attempting the call, so we never even try a call we already know is unsafe e.g. an unsupported city, a rate-limited tool . ============================================================================= SUPPORTED CITIES = {"Paris", "Tokyo", "New York", "London"} async def call with prevention city: str - dict: if city not in SUPPORTED CITIES: We "prevent" the failure by never calling the API for a city we know isn't supported, instead of letting it fail downstream. return {"city": city, "error": "unsupported city", "skipped call": True} return await fetch weather city ============================================================================= STRATEGY 2: ABSORB Run all calls concurrently; if one fails, catch the exception, keep going, and return only the successful results plus a note about what failed . ============================================================================= async def call with absorption cities: list str - dict: tasks = fetch weather city for city in cities return exceptions=True means a failed task returns its Exception object instead of crashing the whole asyncio.gather call. results = await asyncio.gather tasks, return exceptions=True successes, failures = , for city, result in zip cities, results : if isinstance result, Exception : failures.append {"city": city, "error": str result } else: successes.append result return {"successful results": successes, "failed cities": failures} ============================================================================= STRATEGY 3: FAIL GRACEFULLY If ANY call fails and a partial answer would be misleading e.g. this tool call was mandatory context for the user's question , abort entirely and surface a clear, honest message instead of a silently incomplete answer. ============================================================================= async def call with graceful failure cities: list str - dict: tasks = fetch weather city for city in cities try: results = await asyncio.gather tasks no return exceptions: first failure raises return {"ok": True, "results": results} except Exception as exc: return { "ok": False, "refused": True, "reason": f"Could not complete weather comparison: {exc}", } async def main : cities = "Paris", "Tokyo", "New York" print "PREVENT strategy one unsupported city :" print await asyncio.gather call with prevention c for c in "Paris", "Atlantis" print "\nABSORB strategy best-effort, partial results ok :" print await call with absorption cities print "\nFAIL GRACEFULLY strategy all-or-nothing :" print await call with graceful failure cities if name == " main ": asyncio.run main Which strategy to pick is a product decision, not a technical one — that's the "it depends" in the notes. A weather-comparison feature can tolerate "absorb" 2 out of 3 cities is still useful . A financial transaction pipeline probably needs "fail gracefully" a half-completed transfer is dangerous, not just incomplete . Say you're searching your email for something. If you search "birthday party invite," a smart search that understands meaning semantic search will find an email titled "Come celebrate turning 30 " even though none of your exact words appear in it — because it understands the concept of a birthday party. But now say you search for an exact error code, like 1099-MISC . A meaning-based search might get confused and return generic tax documents about "how do I reset my password" style topics — it doesn't realize you need an exact string match , not a conceptual one. A classic old-school keyword search like Ctrl+F, but smarter would nail this instantly because it's built for exact-term matching. Neither approach alone is good at everything. The fix: run both kinds of search at the same time , and combine merge and rerank their results — a bit like asking two friends with different strengths for restaurant recommendations, and combining their two rankings into one better, more trustworthy ranking. Dense retrieval semantic search : your query and your documents are both converted into vectors "embeddings" that capture meaning . Two pieces of text that mean similar things end up as vectors that are close together in space, even if they don't share any exact words. This is great when there's genuine semantic similarity — the notes' example: "How do I reset my password?" naturally matches a document titled "Account Recovery Steps," even with zero overlapping words. Sparse retrieval / BM25 : this is the notes' name for traditional keyword-based search "BM25" = "Best Matching 25," a well-known statistical formula scoring how well a document's exact terms match a query's exact terms, weighted by term rarity and frequency . BM25 is the opposite strength of dense search: it's excellent at exact terms, codes, IDs, acronyms — things like "error code 1099-MISC" — but it has no concept of meaning, so it's poor at "How do I reset my password?" style natural-language questions where the right document doesn't share your exact wording. Hybrid search = dense + sparse, run together. The notes' rule of thumb: "20|50 documents certainly having the right answer" — hybrid retrieval improves recall the fraction of genuinely relevant documents that get retrieved at all , because you're covering both failure modes semantic gap AND exact-term gap simultaneously. The fix line from the notes: "Run both and merge and rerank " Reciprocal Rank Fusion RRF is the merging technique. Analogy: imagine two friends each independently rank the same 10 restaurants from 1 best to 10 worst . To get one combined ranking that reflects both opinions, you don't just average their scores their internal scoring "scales" may be totally different: one friend rates out of 5 stars, another out of 100 points . Instead, RRF says: only the rank position matters, not the internal score — a restaurant ranked 1 by both friends should shoot to the top of the combined list, even if their raw scores were on incompatible scales. This "brings reciprocal ranks together" as the notes put it. The formula , straight from the notes: RRF d = Σ 1 / k + rank r d r∈R Read this piece by piece: d is a particular document you're scoring. R is the set of ranked result lists you're fusing in our case: the dense-search ranking and the BM25 ranking . rank r d is the position of document d in ranking r 1st place, 2nd place, etc. . k is a small constant, 1 / k + rank for The notes' visual intuition: dense list gives fractions like 1/1, 1/2, ... and sparse list gives 1/61, 1/62, ... i.e., 1/ 60+1 , 1/ 60+2 , ... — " Fusion is better " because a document doesn't need to win outright in either individual list; it just needs to consistently rank reasonably well across both to accumulate a high combined score. One more practical note from the notes: Weaviate and Elasticsearch ES support hybrid search natively — meaning they've built RRF-style fusion directly into the database. Otherwise, you have to do it yourself with a scatter-gather pattern fire off both queries yourself, gather both result sets back in your application, then fuse them . ============================================================================= Reciprocal Rank Fusion RRF — implemented from the raw formula. RRF d = sum over each ranked list r containing d of 1 / k + rank r d ============================================================================= def reciprocal rank fusion ranked lists: list list str , k: int = 60 - list tuple str, float : """ ranked lists: a list of ranked result lists. Each inner list is already sorted best-to-worst, e.g. "doc3", "doc1", "doc9" . k: the RRF damping constant 60 is the standard default from the notes . Returns a list of doc id, fused score sorted from best to worst. """ scores: dict str, float = {} for ranked list in ranked lists: for position, doc id in enumerate ranked list : rank = position + 1 ranks start at 1, not 0 This is the exact formula: 1 / k + rank scores doc id = scores.get doc id, 0.0 + 1.0 / k + rank Sort documents by their fused score, highest first fused ranking = sorted scores.items , key=lambda pair: pair 1 , reverse=True return fused ranking --- Example usage --------------------------------------------------------- Dense semantic search results for "how do I reset my password?" dense results = "account recovery steps", "login faq", "security settings", "billing faq" BM25 keyword search results for the SAME query — note it ranks things differently because it only sees exact word overlap sparse results = "login faq", "account recovery steps", "terms of service", "billing faq" fused = reciprocal rank fusion dense results, sparse results , k=60 for doc id, score in fused: print f"{doc id}: {score:.5f}" Expected behaviour: "account recovery steps" and "login faq" both rank highly in BOTH lists, so their fused scores rise above documents that only appeared in one list or ranked poorly in both. This shows the SHAPE of a real hybrid search call: one sparse BM25 query, one dense vector query, run independently, then fused with RRF. rank bm25 and sentence-transformers are common lightweight libraries. pip install rank bm25 sentence-transformers --break-system-packages from rank bm25 import BM25Okapi from sentence transformers import SentenceTransformer import numpy as np documents = "To reset your password, go to Account Recovery and click 'Forgot Password'.", "Error code 1099-MISC indicates a mismatch in the tax reporting form.", "Our billing FAQ covers refunds, invoices, and subscription changes.", doc ids = "account recovery", "error 1099 misc", "billing faq" query = "how do I reset my password" --- Sparse BM25 leg: pure keyword/token overlap scoring ----------------- tokenized corpus = doc.lower .split for doc in documents bm25 = BM25Okapi tokenized corpus bm25 scores = bm25.get scores query.lower .split sparse ranking = doc ids i for i in np.argsort bm25 scores ::-1 --- Dense semantic leg: embed query + docs, rank by cosine similarity --- embedder = SentenceTransformer "all-MiniLM-L6-v2" small, fast embedding model doc embeddings = embedder.encode documents, normalize embeddings=True query embedding = embedder.encode query, normalize embeddings=True cosine scores = doc embeddings @ query embedding dot product of normalized vectors = cosine similarity dense ranking = doc ids i for i in np.argsort cosine scores ::-1 print "Sparse BM25 ranking:", sparse ranking print "Dense semantic ranking:", dense ranking --- Fuse both rankings with RRF reusing the function from section 4.3 --- final ranking = reciprocal rank fusion dense ranking, sparse ranking , k=60 print "Final hybrid ranking:", final ranking Imagine you're searching your email for a specific message and you know it arrived last week. You could a tell your email client "only show me emails from last week," and then search the text — that's fast and narrow. Or you could b search the text across your entire mailbox history, get back thousands of results, and manually throw away everything not from last week — that's slow and wasteful, since your search engine did a ton of unnecessary work scanning old emails it was always going to discard. Now a second, more serious example: at a company, an HR employee should be able to see every department's HR documents, but a regular software engineer should only see documents relevant to their own team — they should never even see, let alone search inside, another department's confidential HR files. This isn't just a nice-to-have; it's a hard security requirement, often called RBAC Role-Based Access Control — a plain-English one-liner: "restricting what a user can see or do based on their assigned role" . Metadata filtering applies hard constraints like date ranges, document owner, department, tags either before or after the vector search runs, on top of whatever relevance ranking your retrieval produces. The notes' example query: "What was our Q1 2026 revenue?" — you'd want to filter to date range = Q1 2026 before even letting semantic search loose on the whole corpus. Beyond just retrieval quality, the notes stress this is especially important for Enterprise RAG and RBAC : an employee simply must not be able to retrieve another department's HR docs, no matter how semantically relevant those docs might look to their query. This is a security boundary, not just a relevance tweak. There are two places you can apply the filter: ============================================================================= PRE-FILTERING: the filter is passed INTO the vector database query itself. The DB never even considers documents outside the RBAC/date boundary, so ranking + filtering happen together, efficiently, in one pass. Qdrant-style filter syntax, as referenced in the notes' prototype. ============================================================================= pip install qdrant-client --break-system-packages from qdrant client import QdrantClient from qdrant client.models import Filter, FieldCondition, MatchValue, Range client = QdrantClient url="http://localhost:6333" assumes a running Qdrant instance query vector = 0.12, 0.98, 0.31 a real embedding would be much longer, e.g. 1536-dim results = client.search collection name="hr documents", query vector=query vector, query filter=Filter must= RBAC constraint: only documents this user's department may see FieldCondition key="department", match=MatchValue value="engineering" , Date constraint: only Q1 2026 documents FieldCondition key="date", range=Range gte="2026-01-01", lte="2026-03-31" , , limit=10, Qdrant applies the filter WHILE searching, so all 10 results returned are guaranteed to satisfy both constraints — no wasted compute, no leakage risk. ============================================================================= POST-FILTERING: search everything first, THEN discard invalid results in plain Python. Shown for contrast — this is what the notes call "wasteful." ============================================================================= def post filter search all search results: list dict , user department: str - list dict : """ all search results: e.g. the top-K matches from an UNFILTERED vector search across the entire corpus, regardless of department. """ filtered = doc for doc in all search results if doc "department" == user department and "2026-01-01" <= doc "date" <= "2026-03-31" return filtered Danger illustrated: if the unfiltered top-10 search returns mostly documents from OTHER departments because they happened to be semantically similar , post-filtering might leave you with just 1-2 usable results, or worse — if the filtering logic has a bug, a forbidden document could slip through into the LLM's context before being filtered, which is a real security risk with RBAC-sensitive data. Pre-filtering avoids this entirely. Bi-encoder analogy: imagine two strangers each write their own short bio independently — "I like hiking, sci-fi movies, and cooking Italian food" and "I enjoy the outdoors, watching space films, and making pasta." You then compare these two bios after the fact to guess how compatible these two people might be. It's fast you can pre-write and store millions of bios ahead of time and just compare later , but it's a bit shallow — the two people never actually talked to each other, so subtle nuances get missed. Cross-encoder analogy: now imagine instead you sit those same two strangers down together for an actual conversation. They can react to each other in real time, clarify ambiguous statements, and pick up on nuance that never would have shown up in two separately-written bios. You get a much better read on true compatibility — but it only works one pair at a time, and a live conversation takes real time and effort per pair. You couldn't have every person in a stadium have a real conversation with every other person — it simply wouldn't scale. Bi-encoders used for the first stage of dense retrieval, described back in Section 4 : the query is turned into an embedding, and each document is turned into an embedding, completely independently of each other. Relevance is then just vector proximity — how close the two embeddings are measured via cosine similarity . Because documents can be embedded once, offline, ahead of time, bi-encoders are fast at query time and scalable — this is why they're "good enough for most" use cases, per the notes. Their key weakness, from the notes: "No cross attention, i.e. query and doc never see each other." Because the two embeddings were produced in isolation, the model can't attend to specific interactions between query terms and document terms. This makes bi-encoders weaker on negation, exact terms, etc. — e.g., a bi-encoder might not clearly distinguish "the flight is NOT delayed" from "the flight IS delayed," because both sentences produce very similar embeddings overall they share almost all the same words . Cross-encoders , by contrast, take the