DZone
Thanks for visiting DZone today,
Edit Profile
  • Manage Email Subscriptions
  • How to Post to DZone
  • Article Submission Guidelines
Sign Out View Profile
  • Post an Article
  • Manage My Drafts
Over 2 million developers have joined DZone.
Log In / Join
Refcards Trend Reports
Events Video Library
Refcards
Trend Reports

Events

View Events Video Library

Related

  • Retrieval Augmented Generation With Spring AI 2.0, Claude, and PGvector
  • Building Production-Ready AI Vector Search in Databricks: Chunking, Embeddings, ML Pipelines, and RAG
  • How to Build a Production-Ready RAG Pipeline With Vector DBs
  • Reducing RAG Hallucinations With Relationship-Aware Retrieval

Trending

  • Pipelines on Fire: Why Your CI/CD Tools Are the New Cyber Battlefield
  • A Firewall for AI Agents: Enforce Authority at Every Tool Call
  • Dynamic Arrays, Spill, and LET: What Changed in Excel and Why It Matters for Java Applications
  • Everybody Wants to Be a Dev!
  1. DZone
  2. Data Engineering
  3. AI/ML
  4. Common Pitfalls in RAG Applications: What to Avoid When Using Vector Search and Embeddings

Common Pitfalls in RAG Applications: What to Avoid When Using Vector Search and Embeddings

RAG is easy to launch and hard to keep reliable. Here are the most common failure modes, their causes, and the Databricks-specific fixes.

By 
Seshendranath Balla Venkata user avatar
Seshendranath Balla Venkata
·
Sep. 17, 26 · Analysis
Likes (0)
Comment
Save
Tweet
Share
158 Views

Join the DZone community and get the full member experience.

Join For Free

Here's the demo that always works: you point a notebook at a vector index, ask it a question, and it answers perfectly. Everyone claps. Three weeks later, the same system tells a customer that your refund window is 90 days when it's 30, cites a document that doesn't exist, and occasionally surfaces another tenant's invoice in the context. Nobody clapped for that part.

RAG is deceptively easy to stand up and genuinely hard to keep honest. The retrieval step looks like a solved problem — embed the query, find the nearest neighbors, stuff them into a prompt — so teams treat it like plumbing and move on. Then quality quietly erodes, and because there's no eval harness, nobody can say when it broke or why. 

I've watched more RAG projects die from unmeasured drift than from any modeling problem. This article is a tour of the failure modes you will actually hit on Databricks Vector Search, each with the symptom, the root cause, and the specific fix. It's opinionated on purpose. Retrieval quality is not vibes.

The RAG failure surface: where things break between a document and an answer.

Pitfall 1: Chunking Like You’re Slicing Bread

Chunking is the most ignored, highest-leverage knob in the whole pipeline. The lazy default is a fixed 1000-character window with zero overlap, applied to everything from API reference pages to legal contracts. It feels reasonable. It is not.

Two failure shapes show up. 

Chunks too big: You embed a 4,000-token wall of text, the embedding becomes an average of six unrelated topics, and cosine similarity goes mushy — every query is sort of close to everything. 

Chunks too small: You split mid-sentence, the retriever returns ...the maximum is, and the model confidently invents the rest. The worst version is splitting on raw character count straight through a table or a code block, so the header lands in chunk 7 and the values land in chunk 8, and neither is useful alone.

Fix it by chunking on structure first and size second. Split on headings and paragraph boundaries, keep tables and code blocks intact as their own chunks, and add a modest overlap so a thought that straddles a boundary survives in at least one chunk. Match the chunk size to your embedding model's real context window — databricks-gte-large-en handles 8,192 tokens, databricks-bge-large-en only 512, so a 1,000-token chunk silently truncates under bge and you embed half a paragraph.

Python
 
from langchain_text_splitters import RecursiveCharacterTextSplitter

# Split on structure first (headings, paragraphs, lines), size second.
# Tokens, not characters — match the embedding model's real window.
splitter = RecursiveCharacterTextSplitter(
    separators=["\n## ", "\n### ", "\n\n", "\n", ". ", " "],
    chunk_size=800,        # tokens, comfortably under gte-large-en's 8192
    chunk_overlap=120,     # ~15% overlap so straddling thoughts survive
    length_function=lambda t: len(tokenizer.encode(t)),
)

# Keep atomic blocks whole: don't let a table header and its rows split.
def chunk_doc(doc):
    chunks = []
    for block in split_into_blocks(doc):          # your structure-aware pass
        if block.kind in ("table", "code"):
            chunks.append(block.text)             # never split these
        else:
            chunks.extend(splitter.split_text(block.text))
return chunks


Tip:  There is no universal chunk size, but there is a universal debugging move: when an answer is wrong, read the retrieved chunks first. Half the time the model did its job, and the chunk was garbage. You can't fix that in the prompt.

Pitfall 2: Embedding the Query and the Index With Different Brains

This one is subtle because nothing errors. You built the index six months ago with databricks-bge-large-en. Last sprint, someone wrote a new query path and reached for databricks-gte-large-en because it was top of mind. Both output 1024-dimensional vectors, so the dimensions match, the similarity_search call succeeds, and the results are quietly nonsense — you're comparing coordinates from two different vector spaces. Same dimension count, completely different geometry.

The cousin of this bug: you re-embed your corpus with a better model but only rebuild half the index, or you bump the embedding endpoint to a new version and forget the query side. Now your live queries are embedded with v2 and three-quarters of your index is still v1. Recall craters, and there's no exception to point at.

The cure is to stop letting the embedding model be an implicit choice scattered across the codebase. Pin it once, centrally, and — the cleanest option on Databricks — use a Delta Sync index with managed embeddings so Vector Search owns embedding generation for both the index and query_text lookups. You physically cannot mismatch them, because you never embed the query yourself.

Python
 
from databricks.sdk import WorkspaceClient

w = WorkspaceClient()

# Managed-embedding Delta Sync index: Vector Search embeds BOTH the
# source column AND query_text with the SAME endpoint. Mismatch impossible.
EMBEDDING_ENDPOINT = "databricks-gte-large-en" # pin once, here, only here

w.vector_search_indexes.create_index(
name="prod.rag.kb_index",
endpoint_name="rag-endpoint",
primary_key="chunk_id",
index_type="DELTA_SYNC",
delta_sync_index_spec={
"source_table": "prod.rag.kb_chunks",
"embedding_source_columns": [
{"name": "content", "embedding_model_endpoint_name": EMBEDDING_ENDPOINT}
],
"pipeline_type": "TRIGGERED",
"columns_to_sync": ["chunk_id", "content", "doc_id", "tenant_id", "updated_at"],
},
)

# At query time you pass TEXT, never a vector. Same model embeds it server-side.
res = w.vector_search_indexes.query_index(
index_name="prod.rag.kb_index",
columns=["chunk_id", "content", "doc_id"],
query_text="What is the refund window?",
num_results=5,
)


Watch Out:  If you must use self-managed embeddings (embedding_vector_columns), treat the model name and version as part of your schema. Write it into a column on the source table, assert it at query time, and re-embed the whole corpus on any change — not the half you remembered.

Pitfall 3: The Index That Never Updates (You Forgot Change Data Feed)

Symptom: You edit a document, the source Delta table clearly has the new text, you trigger a sync, the sync reports success — and the retriever still serves the old answer. People burn a full afternoon on this one. The endpoint is healthy, the index says ONLINE, nothing is red. It's just stale.

Root Cause: A Delta Sync index syncs incrementally off the source table's Change Data Feed. If CDF was never enabled on the table, there's no change stream for the sync pipeline to read, so it has nothing to apply. Depending on how the table was created, you either get a hard error at index-create time or, worse, a sync that completes against an empty changelog and updates nothing. Either way, stale results.

SQL
 
-- The fix is one table property. Enable it on the SOURCE table before
-- (or right after) you create the delta-sync index.
ALTER TABLE prod.rag.kb_chunks
  SET TBLPROPERTIES (delta.enableChangeDataFeed = true);

-- New tables: bake it in at creation so this never bites you.
CREATE TABLE prod.rag.kb_chunks (
  chunk_id   STRING,
  doc_id     STRING,
  tenant_id  STRING,
  content    STRING,
  updated_at TIMESTAMP
) TBLPROPERTIES (delta.enableChangeDataFeed = true);


After enabling CDF, trigger the sync explicitly if you're on a TRIGGERED pipeline — it does not auto-run on source writes. This is the other half of the staleness story. People assume TRIGGERED means "sync when the table changes." It means "sync when you call sync." If you need the index to track writes automatically, that's CONTINUOUS.

Python
 
# TRIGGERED pipelines do NOT auto-sync. You call it, then poll until ready.
w.vector_search_indexes.sync_index(index_name="prod.rag.kb_index")

idx = w.vector_search_indexes.get_index(index_name="prod.rag.kb_index")
print(idx.status.ready, idx.status.indexed_row_count)
# If indexed_row_count never moves after edits -> check CDF on the source table.


Pipeline type

Sync Behavior

Cost

Use When

TRIGGERED

Syncs only when you call sync_index()

Lower — compute runs on demand

Batch refreshes, nightly doc loads, cost-sensitive

CONTINUOUS

Auto-syncs as the source table changes

Higher — pipeline always on

Live freshness, docs that change through the day

Note:  CONTINUOUS indexes cannot be manually synced — calling sync_index() on one raises an error. If your debugging instinct is "just hit sync again," check which pipeline type you actually have first.

Pitfall 4: Plausible-But-Wrong Context, No Filtering, and Cross-Tenant Leakage

Vector search always returns something. Ask about a product you don't sell, and you'll still get five neighbors back, ranked by similarity, looking authoritative. The model then dutifully grounds its answer in those five irrelevant chunks and produces a fluent, specific, completely wrong reply. This is the hallucination people blame on the LLM when the real culprit is retrieval handing it bad context with a straight face.

The dangerous version is multi-tenant. If your index holds documents for many customers and you query without a tenant filter, nearest-neighbor search does not care about ownership — it'll happily return tenant B's contract to tenant A because it's semantically close. That's not a quality bug, but a data-leak incident. I have seen this ship to production because the filter was "on the backlog."

Fix it on two fronts. First, always filter by the metadata that scopes the request — tenant, document type, recency — so the candidate set is correct before similarity even runs. Note the syntax differs by endpoint type: Standard endpoints take dict-style filters_json; Storage-optimized endpoints take SQL-like string filters via the databricks-vectorsearch client. Second, set a similarity floor: if the best match is below a threshold, treat it as "no relevant context found" and have the model say so instead of grounding on noise.

Python
 
from databricks.vector_search.client import VectorSearchClient

vsc = VectorSearchClient()
index = vsc.get_index(endpoint_name="rag-endpoint", index_name="prod.rag.kb_index")

# Storage-Optimized endpoint: SQL-like string filters.
# tenant_id is NON-NEGOTIABLE — it scopes the candidate set before ANN runs.
resp = index.similarity_search(
query_text=user_query,
columns=["chunk_id", "content", "doc_id"],
num_results=8,
filters=f"tenant_id = '{tenant_id}' AND doc_type IN ('policy','faq')",
)
rows = resp["result"]["data_array"] # last column of each row is the score

# Similarity floor: refuse to ground on weak matches instead of hallucinating.
SIM_FLOOR = 0.72
grounded = [r for r in rows if r[-1] >= SIM_FLOOR]
if not grounded:
answer = "I don't have a document that answers that." # honest > fluent
else:
answer = generate(user_query, context=grounded)


Watch Out:  Never interpolate tenant scope into a filter string from raw user input — derive tenant_id from the authenticated session, server-side. A tenant filter the user can override is not a tenant filter. Better still, enforce isolation in Unity Catalog with row-level policies on the source table so the index can only ever sync rows the caller may see.

Pitfall 5: Stuffing the Whole Corpus Into the Context Window

Bigger context windows tempted everyone into a bad habit: "retrieval is fuzzy, so just send top-20 and let the model sort it out." Two things go wrong. You pay for — and wait on — thousands of tokens of mostly irrelevant text on every call. And you walk straight into lost-in-the-middle: models reliably attend to the start and end of a long context and skim the middle, so the one chunk that actually answered the question — sitting at position 11 of 20 — gets ignored. The right answer was in the prompt. The model never read it.

More retrieved chunks is not more knowledge; past a point it's more noise and worse recall of what matters. Retrieve a wider candidate set if you like, but then rerank and trim to a tight, high-precision few, and order them so the strongest land where the model actually looks.

Python
 
# Retrieve wide, then rerank and KEEP FEW. Quality over volume.
candidates = index.similarity_search(
query_text=user_query, columns=["chunk_id", "content"],
num_results=20, filters=f"tenant_id = '{tenant_id}'",
)["result"]["data_array"]

reranked = reranker.rank(user_query, [c[1] for c in candidates]) # cross-encoder
top = reranked[:5] # trim hard

# Lost-in-the-middle hedge: put the strongest chunk LAST (nearest the question).
ordered = sorted(top, key=lambda c: c.score) # ascending -> best at end
context = "\n\n---\n\n".join(c.text for c in ordered)
prompt = f"Use only the context below.\n\n{context}\n\nQuestion: {user_query}"


Tempting move

What it actually does

Do this instead

Send top-20 chunks

Lost-in-the-middle; high token cost; recall drops

Retrieve wide, rerank, keep top 3–5

No reranking

ANN order ≠ relevance order

Cross-encoder rerank the candidate set

Random chunk order

Best chunk buried in the middle

Put strongest chunk at the edges

Raw chunk dump

Model can't tell sources apart

Delimit chunks; cite doc_id in the answer

Pitfall 6: “We Never Measured It”

This is the one that actually kills projects. Every pitfall above is survivable if you can see it. The fatal mistake is shipping RAG with no evaluation harness, so quality becomes a matter of opinion, and the loudest anecdote wins. Someone says "it feels worse since the re-embed," someone else says "works for me," and there's no number to settle it. You can't improve what you refuse to measure.

On Databricks, the harness is mlflow.genai.evaluate() with built-in LLM-judge scorers. The two that matter most for RAG live exactly at the failure modes above: RetrievalGroundedness checks whether the answer is actually supported by the retrieved chunks (catches Pitfall 4's confident fiction), and RelevanceToQuery checks whether the answer addresses the question at all. Add Correctness when you have ground-truth expected_facts. These are real judges, not heuristics — they read the trace and reason about it.

Python
 
import mlflow
from mlflow.entities import SpanType
from mlflow.genai.scorers import RetrievalGroundedness, RelevanceToQuery, Correctness

mlflow.set_tracking_uri("databricks")
mlflow.set_experiment("/Shared/rag-eval")

# RetrievalGroundedness needs a RETRIEVER span in the trace — so trace retrieval.
@mlflow.trace(span_type=SpanType.RETRIEVER)
def retrieve(query, tenant_id):
rows = index.similarity_search(
query_text=query, columns=["chunk_id", "content"],
num_results=5, filters=f"tenant_id = '{tenant_id}'",
)["result"]["data_array"]
return [{"page_content": r[1], "metadata": {"chunk_id": r[0]}} for r in rows]

@mlflow.trace
def rag_app(query, tenant_id):
docs = retrieve(query, tenant_id)
return {"response": generate(query, docs)}

# A small, curated eval set with ground truth beats a big unlabeled one.
eval_data = [
{"inputs": {"query": "What is the refund window?", "tenant_id": "acme"},
"expectations": {"expected_facts": ["Refunds are accepted within 30 days"]}},
{"inputs": {"query": "Do you support SSO?", "tenant_id": "acme"},
"expectations": {"expected_facts": ["SAML and OIDC single sign-on are supported"]}},
]

results = mlflow.genai.evaluate(
data=eval_data,
predict_fn=rag_app,
scorers=[RetrievalGroundedness(), RelevanceToQuery(), Correctness()],
)
print(results.metrics) # now "feels worse" becomes a number that moved


Run this on every change — new chunking strategy, new embedding model, new reranker — as a regression gate, not a one-time blessing. When a metric drops, MLflow Tracing tells you where: open the failing trace, look at the RETRIEVER span, and read what actually came back. The debugging loop is tight: bad answer → inspect retrieved chunks in the trace → was the right chunk even retrieved? If no, it's a retrieval problem (chunking, embedding, filter, staleness). If yes but the answer ignored it, it's a generation problem (context order, prompt, lost-in-the-middle).

The RAG debugging loop with MLflow Tracing + evaluation.



Metric

What it tells you

How to get it

Retrieval groundedness

Is the answer supported by retrieved chunks?

RetrievalGroundedness() scorer (needs RETRIEVER span)

Relevance to query

Does the answer address the question?

RelevanceToQuery() scorer

Correctness

Does it match known facts?

Correctness() scorer + expected_facts

Context recall

Did retrieval find the chunk that holds the answer?

Compare retrieved chunk_ids vs labeled relevant ids

Context precision

What fraction of retrieved chunks are relevant?

Custom @scorer over the RETRIEVER span

Retrieval latency

Is the retrieve step the bottleneck?

Span duration in the trace

Pitfall 7: Treating Metadata and Governance as Someone Else’s Job

The last pitfall is architectural. Teams flatten everything into (chunk_id, content) and throw away the metadata — doc_id, tenant_id, doc_type, updated_at, source URL. Then they can't filter (Pitfall 4), can't cite sources, can't expire stale docs, and can't answer the auditor who asks "why did the model say that?" because there's no path back from an answer to the document it came from.

Carry metadata through the whole pipeline and put governance underneath it. Keep the source table in Unity Catalog's three-level namespace (catalog.schema.table), include the columns you need to filter and cite in columns_to_sync, stamp updated_at, and govern access on the source — the index inherits what the table exposes. The payoff compounds: the same tenant_id that prevents leakage also powers citations, recency filters, and lineage. Metadata is not overhead; it's the thing that makes retrieval auditable.

Pitfall

Symptom

Root Cause

Fix

Bad chunking

Mushy similarity or truncated answers

Fixed-size splits ignore structure/model window

Structure-aware splitter, overlap, size to model

Embedding mismatch

Nonsense results, no error

Query and index embedded by different models/versions

Managed-embedding delta-sync; pin model centrally

Index staleness

Edits don't show up after sync

CDF off; or TRIGGERED never synced

enableChangeDataFeed=true; sync_index() or CONTINUOUS

Plausible-but-wrong/leakage

Confident wrong answers; other tenant's data

No metadata filter; no similarity floor

Server-side tenant filter; similarity threshold

Context overstuffing

Slow, costly, ignores the right chunk

Top-20 dump; lost-in-the-middle

Rerank, trim to 3–5, order by edge position

No evaluation

"Feels worse" debates, silent drift

Shipped with no eval harness

mlflow.genai.evaluate as a regression gate

Ignored metadata

Can't filter, cite, or audit

Flattened to id+text; no governance

Carry metadata; govern source in Unity Catalog

The Takeaway

None of these failure modes are exotic. They're the default outcome of treating RAG as plumbing — chunk however, embed whatever, sync if you remember, send a pile of context, and hope. The fix in every case is the same posture: make retrieval explicit and measurable. Chunk on structure. Pin one embedding model and let managed delta-sync enforce it. 

Enable change data feed before you wonder why nothing updates. Filter by tenant server-side and refuse weak matches. Rerank and trim instead of dumping. And above all, wire up mlflow.genai.evaluate() with RetrievalGroundedness and RelevanceToQuery so "it got worse" becomes a number, and use MLflow Tracing to find out exactly which span betrayed you.

If you can't open a trace and read the chunks your model was handed, you're not debugging RAG — you're guessing.

Start small: stand up a Delta Sync index with managed embeddings, put twenty labeled questions behind mlflow.genai.evaluate(), and make that eval a gate on every change. The Databricks Vector Search and MLflow GenAI evaluation docs walk through both end-to-end. Build the harness before you build the features — your future self, staring at a confidently wrong answer at 4 p.m., will thank you.

Data structure RAG

Opinions expressed by DZone contributors are their own.

Related

  • Retrieval Augmented Generation With Spring AI 2.0, Claude, and PGvector
  • Building Production-Ready AI Vector Search in Databricks: Chunking, Embeddings, ML Pipelines, and RAG
  • How to Build a Production-Ready RAG Pipeline With Vector DBs
  • Reducing RAG Hallucinations With Relationship-Aware Retrieval

Partner Resources

×

Comments

The likes didn't load as expected. Please refresh the page and try again.

  • RSS
  • X
  • Facebook

ABOUT US

  • About DZone
  • Support and feedback
  • Community research

ADVERTISE

  • Advertise with DZone

CONTRIBUTE ON DZONE

  • Article Submission Guidelines
  • Become a Contributor
  • Core Program
  • Visit the Writers' Zone

LEGAL

  • Terms of Service
  • Privacy Policy

CONTACT US

  • 3343 Perimeter Hill Drive
  • Suite 215
  • Nashville, TN 37211
  • [email protected]

Let's be friends:

  • RSS
  • X
  • Facebook