Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Hybrid search combining semantic and keyword retrieval for RAG pipelines. Implement BM25 + dense vector search with fusion strategies.
.claude/skills/a5c-ai-rag-hybrid-search/SKILL.md| Model | Eval pass | Runs |
|---|---|---|
| gemini-3.6-flash | 100% | 2 |
| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-01 | ✗→✓ | ▲ Improved | 53% | 0% |
| case-04 | ✗→✓ | ▲ Improved | 79% | 0% |
| case-08 | ✗→✓ | ▲ Improved | 111% | 0% |
| case-09 | ✗→✓ | ▲ Improved | 137% | 0% |
| case-06 | ✓→✓ | = Same ✓ | 31% | 0% |
Implement hybrid search combining semantic vector retrieval with keyword-based BM25 search for improved RAG pipeline accuracy and recall.
Hybrid search addresses the limitations of pure semantic or pure keyword search:
pythonfrom langchain_community.retrievers import BM25Retriever from langchain_community.vectorstores import Chroma from langchain.retrievers import EnsembleRetriever from langchain_openai import OpenAIEmbeddings # Create documents docs = [...] # Your document chunks # Dense retriever (semantic) embeddings = OpenAIEmbeddings() vectorstore = Chroma.from_documents(docs, embeddings) dense_retriever = vectorstore.as_retriever(search_kwargs={"k": 5}) # Sparse retriever (BM25) bm25_retriever = BM25Retriever.from_documents(docs) bm25_retriever.k = 5 # Hybrid ensemble hybrid_retriever = EnsembleRetriever( retrievers=[bm25_retriever, dense_retriever], weights=[0.4, 0.6] # Adjust based on use case ) # Query results = hybrid_retriever.invoke("How do I configure the system?")
pythondef reciprocal_rank_fusion(results_lists: list, k: int = 60) -> list: """ Combine multiple ranked lists using RRF. k is a constant (typically 60) for smoothing. """ fused_scores = {} for results in results_lists: for rank, doc in enumerate(results): doc_id = doc.metadata.get("id", str(doc.page_content[:50])) if doc_id not in fused_scores: fused_scores[doc_id] = {"doc": doc, "score": 0} fused_scores[doc_id]["score"] += 1 / (k + rank + 1) # Sort by fused score sorted_docs = sorted( fused_scores.values(), key=lambda x: x["score"], reverse=True ) return [item["doc"] for item in sorted_docs] # Use with multiple retrievers semantic_results = dense_retriever.invoke(query) keyword_results = bm25_retriever.invoke(query) hybrid_results = reciprocal_rank_fusion([semantic_results, keyword_results])
pythonfrom pinecone import Pinecone from pinecone_text.sparse import BM25Encoder # Initialize Pinecone pc = Pinecone(api_key="your-api-key") index = pc.Index("hybrid-index") # Prepare sparse encoder bm25 = BM25Encoder() bm25.fit(corpus) # Fit on your document corpus def hybrid_query(query: str, alpha: float = 0.5, top_k: int = 10): """ Query with hybrid search. alpha: weight for dense vectors (1-alpha for sparse) """ # Get dense embedding dense_embedding = embeddings.embed_query(query) # Get sparse embedding sparse_embedding = bm25.encode_queries([query])[0] # Hybrid query results = index.query( vector=dense_embedding, sparse_vector=sparse_embedding, top_k=top_k, include_metadata=True ) return results
pythonimport weaviate client = weaviate.Client("http://localhost:8080") def weaviate_hybrid_search(query: str, alpha: float = 0.5, limit: int = 10): """ Weaviate native hybrid search. alpha: 0 = pure BM25, 1 = pure vector """ result = ( client.query .get("Document", ["content", "title", "metadata"]) .with_hybrid( query=query, alpha=alpha, properties=["content", "title"] ) .with_limit(limit) .do() ) return result["data"]["Get"]["Document"]
javascriptconst ragHybridSearchTask = defineTask({ name: 'rag-hybrid-search-setup', description: 'Configure hybrid search for RAG pipeline', inputs: { vectorStore: { type: 'string', required: true }, // 'pinecone', 'weaviate', 'chroma', etc. embeddingModel: { type: 'string', default: 'text-embedding-3-small' }, bm25Params: { type: 'object', default: { k1: 1.5, b: 0.75 } }, fusionStrategy: { type: 'string', default: 'rrf' }, // 'rrf', 'weighted', 'custom' denseWeight: { type: 'number', default: 0.6 }, topK: { type: 'number', default: 10 } }, outputs: { retrieverConfigured: { type: 'boolean' }, indexStats: { type: 'object' }, artifacts: { type: 'array' } }, async run(inputs, taskCtx) { return { kind: 'skill', title: `Configure hybrid search with ${inputs.vectorStore}`, skill: { name: 'rag-hybrid-search', context: { vectorStore: inputs.vectorStore, embeddingModel: inputs.embeddingModel, bm25Params: inputs.bm25Params, fusionStrategy: inputs.fusionStrategy, denseWeight: inputs.denseWeight, topK: inputs.topK, instructions: [ 'Validate vector store connection and configuration', 'Set up dense embedding pipeline', 'Configure BM25/sparse encoding', 'Implement fusion strategy', 'Test retrieval quality with sample queries', 'Document configuration and tuning parameters' ] } }, io: { inputJsonPath: `tasks/${taskCtx.effectId}/input.json`, outputJsonPath: `tasks/${taskCtx.effectId}/result.json` } }; } });
| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-01 | fail→pass | 11,308 | 7,627 | -33% | 1 | 1 | 0% | 2,353 | 3,606 | +53% | 0 | 0 | — |
case-02 | fail→fail | 9,956 | 7,308 | -27% | 1 | 1 | 0% | 1,852 | 3,425 | +85% | 0 | 0 | — |
case-03 | fail→fail | 13,779 | 9,300 | -33% | 1 | 1 | 0% | 2,669 | 3,963 | +48% | 0 | 0 | — |
case-04 | fail→pass | 11,013 | 8,450 | -23% | 1 | 1 | 0% | 2,013 | 3,602 | +79% | 0 | 0 | — |
case-05 | fail→fail | 6,854 | 4,936 | -28% | 1 | 1 | 0% | 1,456 | 3,142 | +116% | 0 | 0 | — |
case-06 | pass→pass | 19,784 | 2,871 | -85% | 1 | 1 | 0% | 1,989 | 2,603 | +31% | 0 | 0 | — |
case-07 | pass→pass | 8,745 | 3,267 | -63% | 1 | 1 | 0% | 1,678 | 2,742 | +63% | 0 | 0 | — |
case-08 | fail→pass | 7,947 | 2,052 | -74% | 1 | 1 | 0% | 1,164 | 2,457 | +111% | 0 | 0 | — |
case-09 | fail→pass | 6,541 | 2,133 | -67% | 1 | 1 | 0% | 1,045 | 2,478 | +137% | 0 | 0 | — |
case-10 | fail→fail | 16,060 | 14,254 | -11% | 1 | 1 | 0% | 2,963 | 4,896 | +65% | 0 | 0 | — |
case-11 | fail→fail | 5,950 | 5,065 | -15% | 1 | 1 | 0% | 1,304 | 3,037 | +133% | 0 | 0 | — |
case-12 | fail→fail | 12,220 | 11,570 | -5% | 1 | 1 | 0% | 2,158 | 4,190 | +94% | 0 | 0 | — |
case-13 | fail→fail | 4,723 | 3,927 | -17% | 1 | 1 | 0% | 963 | 2,881 | +199% | 0 | 0 | — |
case-14 | fail→fail | 4,496 | 3,461 | -23% | 1 | 1 | 0% | 829 | 2,755 | +232% | 0 | 0 | — |
case-15 | fail→fail | 15,106 | 13,698 | -9% | 1 | 1 | 0% | 2,648 | 4,452 | +68% | 0 | 0 | — |
case-16 | fail→fail | 13,735 | 12,719 | -7% | 1 | 1 | 0% | 2,598 | 4,530 | +74% | 0 | 0 | — |
case-17 | fail→fail | 10,343 | 8,078 | -22% | 1 | 1 | 0% | 1,687 | 3,385 | +101% | 0 | 0 | — |
case-18 | fail→fail | 15,187 | 13,722 | -10% | 1 | 1 | 0% | 2,776 | 4,631 | +67% | 0 | 0 | — |
case-19 | fail→fail | 14,074 | 12,117 | -14% | 1 | 1 | 0% | 2,398 | 4,359 | +82% | 0 | 0 | — |
case-20 | fail→fail | 12,103 | 13,604 | +12% | 1 | 1 | 0% | 2,520 | 4,789 | +90% | 0 | 0 | — |
case-21 | fail→fail | 11,715 | 7,372 | -37% | 1 | 1 | 0% | 2,330 | 3,418 | +47% | 0 | 0 | — |
case-22 | fail→fail | 18,722 | 16,795 | -10% | 1 | 1 | 0% | 3,687 | 5,501 | +49% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted. The headline lift of +18 percentage points is the difference between those two pass rates over the 22 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.