skills/rag-retrieval-pattern-design/SKILL.md
Use when designing or fixing the retrieval side of a RAG system, choosing chunking strategy (fixed-size / recursive / semantic), implementing hybrid search (BM25 + dense) with RRF fusion, adding a cross-encoder reranker, evaluating with RAGAS, or running an index-freshness pipeline. Triggers: "RAG keeps citing the wrong doc", chunk size 512 tokens with overlap, RRF reciprocal rank fusion, dense+sparse hybrid, cross-encoder rerank top-5, RAGAS faithfulness / context precision, voyage-3-large vs text-embedding-3-large, daily / hourly reindex cadence. NOT for fine-tuning vs RAG decision (separate skill), agentic tool-use designs, vector DB operational tuning, or general LLM prompt engineering.
npx skillsauth add curiositech/windags-skills rag-retrieval-pattern-designInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
When RAG fails, retrieval is the bug ~73% of the time — and ~80% of those trace to the ingestion / chunking layer. (premai.io — Building Production RAG (2026)) The LLM is faithfully reporting what you handed it; the problem is what you handed it. So the order of work is fixed: chunking → embeddings → hybrid search → rerank → eval → freshness. Get those right and you're at 90% of the achievable quality. Spend the same week on prompt engineering and you'll move 5%.
The compressed 2026 stack:
chunking recursive 512-token chunks, 10-20% overlap
embeddings voyage-3-large (or text-embedding-3-large; pick by MTEB on YOUR domain)
retrieval hybrid: BM25 + dense, fused via RRF (k=60), top-20
rerank cross-encoder, return top-5
eval RAGAS — faithfulness ≥ 0.85, context precision ≥ 0.75
freshness daily reindex; hourly for real-time domains
Jump to your fire:
text-embedding-3-large in 2026.The standard decomposition before debugging anything else:
| Symptom | Likely layer | First-line check | |---|---|---| | Wrong document retrieved | Chunking or embeddings | Inspect chunks for the question; are they retrievable in isolation? | | Right document, wrong chunk | Chunking | Chunks too small / split mid-sentence; or too large / topic-mixed | | Right chunk, ignored by LLM | Reranker / context order | Rerank top-5; put highest-relevance chunk first | | Faithfulness high, answer still wrong | Freshness | Index is stale; re-run the ingestion pipeline | | BM25 finds it, dense doesn't (or vice versa) | Single-retriever bias | Hybrid + RRF | | Empty result set | Filter / index missing | Check zero-result rate metric (>5% is a problem) |
Citing premai.io's framing: "When faithfulness score is high but answers are still wrong, the LLM is accurately reporting what's in the context, but the context itself is wrong or outdated." (premai-rag)
The strategies, in increasing order of cost and (sometimes) quality:
| Strategy | How | Pros | Cons | |---|---|---|---| | Fixed-size | Split every N tokens | Cheap, deterministic | Splits mid-sentence, mid-section | | Recursive | Split by paragraph → sentence → token, prefer the largest natural boundary that fits | Good baseline; preserves structure | Slightly more code | | Semantic | Split where embedding similarity drops below threshold | Topic-aware boundaries | 200–300 extra embedding calls per 10k-word doc (premai-rag) | | Document-structure-aware | Use Markdown headings, code blocks, list boundaries | Best for technical docs | Hand-tuned per format |
Recommended baseline per the 2026 guides (premai-rag):
"Recursive chunking at 512 tokens performed at 69% accuracy, 15 percentage points above semantic chunking… Fixed-size: 512 tokens with 10-20% overlap."
# LangChain-style recursive splitter
from langchain.text_splitter import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=512, # tokens, approximate via len/4 for English
chunk_overlap=64, # ~12% overlap; preserves cross-boundary context
separators=["\n\n", "\n", ". ", " ", ""],
)
chunks = splitter.split_text(doc)
Counter-intuitively, semantic chunking is not always better — it costs more and on common benchmarks underperforms recursive. Try recursive first, prove a benefit before paying for semantic.
For a doc with structured sections (Markdown, HTML, source code): split on heading boundaries first, then within each section use recursive. The structure encodes intent; ignoring it splits across topics.
The 2026 leaderboard shifts; the choice criteria don't:
bge-multilingual-gemma2, multilingual-e5-large, voyage-multilingual-2).Specific 2026 contenders and what the practitioner field is reporting (premai-rag):
| Model | Dim | Strengths | Cost (per 1M tokens, indicative) |
|---|---|---|---|
| voyage-3-large | 1024 | MTEB leader for many domains; cited "9.74% over OpenAI" | ~$0.06 |
| text-embedding-3-large (OpenAI) | 3072 (or 1024) | Solid baseline; widely supported | ~$0.13 |
| bge-large-en-v1.5 (BAAI) | 1024 | Open-weight; can self-host | self-hosted |
| nomic-embed-text-v1.5 | 768 | Open-weight, smaller | self-hosted |
Run a domain eval before committing — leaderboards shift, your corpus is not the average corpus.
Dense embeddings are strong on semantic similarity; BM25 is strong on rare keywords (proper nouns, codes, acronyms). Hybrid search runs both and fuses the rankings.
Reciprocal Rank Fusion (RRF) is the standard fuser: (premai-rag)
RRF_score(d) = sum over retrievers of [ 1 / (k + rank_i(d)) ]
where k = 60 (the conventional constant)
For weighted hybrid (dense vs sparse not equal in your domain):
def rrf_fuse(dense_results, sparse_results, k=60, dense_weight=0.6, sparse_weight=0.4):
scores = {}
for rank, doc_id in enumerate(dense_results, start=1):
scores[doc_id] = scores.get(doc_id, 0) + dense_weight * (1 / (k + rank))
for rank, doc_id in enumerate(sparse_results, start=1):
scores[doc_id] = scores.get(doc_id, 0) + sparse_weight * (1 / (k + rank))
return sorted(scores.items(), key=lambda x: x[1], reverse=True)
The 0.6 dense / 0.4 sparse baseline is a common starting point; tune on your eval set. Domains heavy in proper nouns / IDs / code (legal, finance, dev docs) often go the other way (0.4 / 0.6).
Top-K to retrieve before rerank: 20 is the documented sweet spot per the 2026 guides (premai-rag) — large enough that the relevant doc is in the candidate set, small enough that rerank latency stays under control.
Bi-encoder retrieval (the embedding model) computes query and doc embeddings independently and compares with cosine; fast but ~accuracy ceiling. Cross-encoder reranking runs a model that sees query + candidate together with full attention, scoring each. Slower but much more accurate.
The standard pipeline per premai.io's writeup (premai-rag):
hybrid retrieve (top-20) → cross-encoder rerank → top-5 to LLM
+30–100ms latency +10–30% precision
# Cross-encoder rerank — Cohere Rerank or BGE Reranker, etc.
from sentence_transformers import CrossEncoder
reranker = CrossEncoder('BAAI/bge-reranker-v2-m3')
candidate_docs = hybrid_retrieve(query, top_k=20)
scores = reranker.predict([(query, doc.text) for doc in candidate_docs])
ranked = sorted(zip(candidate_docs, scores), key=lambda x: x[1], reverse=True)
top_5 = [doc for doc, _ in ranked[:5]]
Hosted alternatives: Cohere Rerank, Voyage Rerank, Jina Rerank — managed APIs with similar quality, easier to ship.
Reranking is the highest-ROI single change in most under-performing RAG systems. (premai-rag)
Once you have your top-5, the order matters. LLMs exhibit "lost in the middle" — content at the beginning and end of the context window is recalled better than content in the middle.
LLM context order (top → bottom):
- SYSTEM PROMPT
- USER QUERY
- TOP-RANKED CHUNK
- 2nd, 3rd
- 5th-ranked chunk ← weakest position; place least-critical here
- 4th-ranked
- QUERY (repeated, optional)
Empirically: highest-relevance chunk first AND last (sandwich) often outperforms strict descending order.
You can't improve what you can't measure. RAGAS gives you four interpretable metrics: (premai-rag)
| Metric | What it asks | Threshold (warn / critical) | |---|---|---| | Faithfulness | Does the answer stick to retrieved context? | ≥ 0.85 / < 0.70 | | Answer Relevancy | Does the answer address the question? | ≥ 0.85 | | Context Precision | Are top contexts actually relevant? | ≥ 0.75 | | Context Recall | Does the retrieved context contain the ground truth? | ≥ 0.85 |
from ragas import evaluate
from ragas.metrics import faithfulness, answer_relevancy, context_precision, context_recall
result = evaluate(
dataset=eval_dataset, # questions + ground-truth answers + retrieved contexts
metrics=[faithfulness, answer_relevancy, context_precision, context_recall],
)
Build a small but representative eval set (50–200 questions) by hand once. Re-run on every change. Without an eval set, every "this is better" claim is vibes.
Real-time RAG (chat, search) needs to feel fast. Indicative 2026 budgets (premai-rag):
Target end-to-end: < 2s
Query embedding: 20–50 ms
Hybrid retrieval: 5–30 ms
Reranking (top-20): 30–100 ms
LLM generation: 500 ms – 3 s+
If you're outside this, the bottleneck is almost always LLM generation. Stream the response. Cache hot queries (semantic-cache over the question embedding).
Stale indexes are the silent killer. Monitor:
Reindex cadence:
| Domain | Cadence | |---|---| | Product catalog, regulatory | Daily | | Customer support, news, real-time | Hourly | | Internal docs (engineering, HR) | Weekly is often fine | | Static reference (textbooks, archived) | Re-index on schema change only |
Use change-data-capture (outbox-pattern-implementation) on the source systems to feed an incremental re-index pipeline.
- query, query_embedding_id
- retrieval candidates (id, score, retriever, rank)
- rerank scores
- final top-5 with chunks
- LLM input length, output, latency
- RAGAS scores (when ground truth is available, e.g. canary set)
- user feedback (thumbs up/down) if available
Structured logging — see structured-logging-design. OTel spans for each stage — opentelemetry-instrumentation.
Symptom: Engineers spend a month on prompt engineering. RAGAS score barely moves. Diagnosis: Faithfulness is high, but the wrong context is being retrieved. The LLM is doing its job; the retriever isn't. Fix: Triage retrieval first. ~80% of failures are at chunking + retrieval. (premai-rag)
Symptom: Queries with proper nouns / IDs / acronyms ("CVE-2024-1234", "section 230") miss exact matches that BM25 finds trivially. Diagnosis: No keyword retrieval; embedding cosine doesn't reliably match rare tokens. Fix: Hybrid retrieval with RRF. Dense + BM25, fused.
Symptom: Top-1 is often relevant but top-5 is mostly noise; LLM gets confused by low-quality context. Diagnosis: Bi-encoder retrieval ceiling. Fix: Cross-encoder rerank top-20 → top-5. The single highest-ROI change for most RAG systems. (premai-rag)
Symptom: Faithfulness drops; LLM cherry-picks irrelevant lines. Diagnosis: Huge chunks dilute the relevant signal; the LLM has too many candidates inside one chunk. Fix: 512 tokens with 10–20% overlap. Retrieval matches a focused chunk; LLM gets clarity.
Symptom: Retrieved chunk is missing the surrounding context the LLM needs. Diagnosis: Sub-paragraph chunks lose pronoun resolution and structural context. Fix: ~512 tokens. If sentences need pinning, a separate pass extracts them; chunks stay big enough to be self-contained.
Symptom: "I think the new embedding model is better." How do you know? Diagnosis: No measurable comparison. Fix: 50–200 hand-curated questions with ground-truth answers; run RAGAS on every change.
Symptom: Faithfulness stays 0.9 but customer complaints climb. "Why does it think our policy is the 2023 version?" Diagnosis: No reindex pipeline; corpus drift. Fix: Daily / hourly reindex per domain; CDC feed for incremental updates. Monitor document-age metric.
Symptom: Hybrid is in place but no improvement over dense-only.
Diagnosis: Default weights aren't right for your domain.
Fix: Start 0.6 dense / 0.4 sparse; tune on eval set. Code/legal/finance often invert.
Symptom: "512 worked best in my 5 questions." Production performance disagrees. Diagnosis: Eval set too small to be statistically meaningful. Fix: Eval set ≥ 100 representative queries. Use bootstrapping or confidence intervals on RAGAS scores.
structured-logging-design.opentelemetry-instrumentation.data-ai
license: Apache-2.0 NOT for unrelated tasks outside this domain.
development
Use when designing caching strategies (cache-aside, write-through, write-behind), implementing distributed locks, building rate limiters, leaderboards, real-time streams (XADD/consumer groups), pub/sub, or tuning eviction policies. Triggers: thundering-herd on cache miss, dogpile on key expiry, Redlock vs SET-NX-PX choice, sliding-window rate limiter, hot-key on a single cluster slot, big-key blowup, MULTI/EXEC across slots, KEYS in production. NOT for Redis Cluster operations/admin (different domain), embedded KV (SQLite, leveldb), in-process LRU caches, or Memcached.
tools
Drawing the `'use client'` boundary correctly in React Server Components apps (Next.js App Router, RSC frameworks) — leaf-pushing, slot composition, serialization rules, and environment poisoning prevention. Grounded in react.dev and Next.js 16 docs.
development
Use when designing rate limiting for an API, choosing between token bucket / sliding window / leaky bucket / fixed window, implementing it in Redis, deciding edge (Cloudflare/Upstash) vs origin enforcement, sizing per-user vs per-IP vs per-endpoint quotas, returning the right 429 response with Retry-After, or fixing the boundary-burst bug in fixed-window limiters. Triggers: 429 too many requests, INCR + EXPIRE, ZADD + ZREMRANGEBYSCORE + ZCARD, X-RateLimit-Remaining header, Cloudflare WAF rate limiting rules, Upstash @upstash/ratelimit, leaky bucket shaping vs policing, distributed rate limiter consistency. NOT for DDoS mitigation specifically (different scale), CAPTCHA / bot management, full WAF design, or per-user quota billing.