You have documents — SOPs, batch records, technical reports, regulatory filings — and you want AI-powered search over them. Not the kind that sends your proprietary content to a third-party API for embedding. The kind where every byte stays on your infrastructure, the embeddings are generated locally, and you control the entire retrieval pipeline.
The architecture that works is hybrid RAG: combine dense vector search (semantic meaning) with sparse BM25 keyword matching (exact terms), fuse the results, and send only the relevant chunks to a frontier model for answer generation. This tutorial builds the entire stack in Docker Compose — four containers, one command to start. The complete source code for this reference implementation is available in the hybrid-rag-docker-example GitHub repository.
Why hybrid matters
Pure vector search fails on quality documents. “SOP-QA-007” and “SOP-QA-070” produce nearly identical embeddings. “USP <85>” (Bacterial Endotoxins) and “USP <87>” (Biological Reactivity) are semantically similar text but entirely different specifications. Vector search confuses them because embeddings capture meaning, not exact strings.
Pure BM25 keyword search fails in the opposite direction. Ask “what happens if storage temperature drops below threshold” and it will miss a chunk that says “cold room deviation event triggered when thermal reading falls beneath the minimum.” Same meaning, different words.
Hybrid search catches both. Dense retrieval handles the conceptual match. Sparse retrieval handles the exact term match. Reciprocal Rank Fusion merges the two ranked lists into a single result set that outperforms either method alone.
The architecture
┌────────────────────────── Docker Compose Network ───────────────────────────┐
│ │
│ ┌──────────────┐ ┌──────────────────┐ ┌──────────────────┐ │
│ │ │ │ │ │ │ │
│ │ RustFS │◀──────│ RAG API │──────▶│ Ollama │ │
│ │ (S3 Store) │ │ (Orchestrator) │ │ qwen3-embedding │ │
│ │ :9000 │ │ :8000 │ │ :11434 │ │
│ │ │ │ │ │ │ │
│ └──────────────┘ │ ┌────────────┐ │ └──────┬───────────┘ │
│ │ │ LanceDB │ │ │ │
│ │ │ (Vector + │ │ │ │
│ │ │ BM25 FTS) │ │ │ │
│ │ └───────┬────┘ │ ┌──────▼───────────┐ │
│ └──────────┴───────┘ │ Frontier Model │ │
│ │ │ (OpenAI-compat) │ │
│ │ │ via Ollama │ │
│ ┌─────▼────┐ └──────────────────┘ │
│ │ Client │ │
│ └──────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
The flow:
- Upload a document to RustFS (S3-compatible object storage, written in Rust)
- RAG API downloads the file, parses it, chunks it into overlapping segments
- Ollama generates 4096-dimensional embeddings using
qwen3-embedding:8b - LanceDB stores chunks with both dense vectors and a Tantivy full-text search index
- On query: LanceDB runs vector search AND BM25 keyword search simultaneously
- Results are fused using Reciprocal Rank Fusion (RRF)
- Assembled context is sent to a frontier model via OpenAI-compatible endpoint
- The model generates an answer grounded in your source documents
Project structure
hybrid-rag/
├── docker-compose.yml
├── .env
├── orchestrator/
│ ├── Dockerfile
│ ├── requirements.txt
│ ├── app/
│ │ ├── __init__.py
│ │ ├── main.py
│ │ ├── config.py
│ │ ├── embedding.py
│ │ ├── document_processor.py
│ │ ├── vector_store.py
│ │ ├── hybrid_search.py
│ │ └── llm_client.py
│ └── scripts/
│ └── init-ollama.sh
└── data/
└── sample/
└── sample.txt
Step 1: Docker Compose
This is the only file that ties everything together. Four services, one network, persistent volumes for all state.
version: '3.8'
services:
rustfs:
image: rustfs/rustfs:latest
container_name: rustfs
ports:
- "9000:9000"
- "9001:9001"
environment:
- RUSTFS_ROOT_USER=admin
- RUSTFS_ROOT_PASSWORD=admin123456
volumes:
- rustfs_data:/data
command: server /data --console-address ":9001"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 15s
timeout: 10s
retries: 5
start_period: 30s
restart: unless-stopped
ollama:
image: ollama/ollama:latest
container_name: ollama
ports:
- "11434:11434"
volumes:
- ollama_data:/root/.ollama
- ./scripts/init-ollama.sh:/docker-entrypoint-init.d/init-ollama.sh:ro
# Uncomment for NVIDIA GPU:
# deploy:
# resources:
# reservations:
# devices:
# - driver: nvidia
# count: all
# capabilities: [gpu]
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:11434/api/tags"]
interval: 30s
timeout: 10s
retries: 10
start_period: 60s
restart: unless-stopped
rag-orchestrator:
build:
context: ./orchestrator
dockerfile: Dockerfile
container_name: rag-orchestrator
ports:
- "8000:8000"
environment:
- RUSTFS_ENDPOINT=rustfs:9000
- RUSTFS_ACCESS_KEY=admin
- RUSTFS_SECRET_KEY=admin123456
- RUSTFS_BUCKET=documents
- OLLAMA_HOST=http://ollama:11434
- EMBEDDING_MODEL=qwen3-embedding:8b
- FRONTIER_MODEL_URL=http://ollama:11434/v1
- FRONTIER_MODEL_NAME=qwen3:32b
- LANCEDB_URI=/app/data/lancedb
- CHUNK_SIZE=512
- CHUNK_OVERLAP=50
- TOP_K=5
- DENSE_WEIGHT=0.7
volumes:
- lancedb_data:/app/data/lancedb
depends_on:
rustfs:
condition: service_healthy
ollama:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 15s
timeout: 5s
retries: 5
start_period: 30s
restart: unless-stopped
volumes:
rustfs_data:
ollama_data:
lancedb_data:
RustFS runs as UID 10001. If you hit permission errors on the data volume:
sudo chown -R 10001:10001 ./data/rustfs
Step 2: Ollama initialization script
The orchestrator needs two models: qwen3-embedding:8b for embeddings and qwen3:32b for generation. This script runs inside the Ollama container on first boot and pulls both.
#!/bin/bash
set -e
# Wait for Ollama API
MAX_RETRIES=60
RETRY_COUNT=0
while ! curl -sf http://localhost:11434/api/tags > /dev/null 2>&1; do
RETRY_COUNT=$((RETRY_COUNT + 1))
if [ $RETRY_COUNT -ge $MAX_RETRIES ]; then
echo "ERROR: Ollama API not ready"
exit 1
fi
sleep 5
done
# Pull embedding model
if ! ollama list | grep -q "^qwen3-embedding:8b"; then
echo "Pulling qwen3-embedding:8b..."
ollama pull qwen3-embedding:8b
fi
# Pull frontier model
if ! ollama list | grep -q "^qwen3:32b"; then
echo "Pulling qwen3:32b..."
ollama pull qwen3:32b
fi
echo "Models ready."
ollama list
Make it executable: chmod +x scripts/init-ollama.sh
For lower resource usage, swap qwen3:32b for qwen3:8b — the architecture stays identical.
Step 3: The orchestrator Dockerfile
FROM python:3.11-slim-bookworm
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential curl && \
rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
Step 4: Python dependencies
fastapi==0.115.6
uvicorn[standard]==0.34.0
pydantic==2.10.4
boto3==1.35.99
lancedb==0.20.0
pandas==2.2.3
pyarrow==18.1.0
requests==2.32.3
pypdf==5.1.0
python-multipart==0.0.20
numpy==2.2.1
python-dotenv==1.0.1
tqdm==4.67.1
Step 5: Configuration
# app/config.py
import os
from dataclasses import dataclass, field
@dataclass
class Config:
# RustFS
rustfs_endpoint: str = os.getenv("RUSTFS_ENDPOINT", "rustfs:9000")
rustfs_access_key: str = os.getenv("RUSTFS_ACCESS_KEY", "admin")
rustfs_secret_key: str = os.getenv("RUSTFS_SECRET_KEY", "admin123456")
rustfs_bucket: str = os.getenv("RUSTFS_BUCKET", "documents")
# Ollama
ollama_host: str = os.getenv("OLLAMA_HOST", "http://ollama:11434")
embedding_model: str = os.getenv("EMBEDDING_MODEL", "qwen3-embedding:8b")
# Frontier Model (OpenAI-compatible)
frontier_url: str = os.getenv("FRONTIER_MODEL_URL", "http://ollama:11434/v1")
frontier_model: str = os.getenv("FRONTIER_MODEL_NAME", "qwen3:32b")
frontier_api_key: str = os.getenv("FRONTIER_API_KEY", "ollama")
# LanceDB
lancedb_uri: str = os.getenv("LANCEDB_URI", "/app/data/lancedb")
table_name: str = "documents"
# Chunking
chunk_size: int = int(os.getenv("CHUNK_SIZE", "512"))
chunk_overlap: int = int(os.getenv("CHUNK_OVERLAP", "50"))
# Retrieval
top_k: int = int(os.getenv("TOP_K", "5"))
dense_weight: float = float(os.getenv("DENSE_WEIGHT", "0.7"))
rrf_k: int = 60
settings = Config()
Step 6: Document processing
Handles PDF, DOCX, TXT, and Markdown. Chunks text using recursive character splitting that respects paragraph and sentence boundaries.
# app/document_processor.py
import io
import re
import pypdf
def extract_text(file_content: bytes, filename: str) -> str:
ext = filename.rsplit(".", 1)[-1].lower()
if ext == "pdf":
reader = pypdf.PdfReader(io.BytesIO(file_content))
return "\n".join(page.extract_text() or "" for page in reader.pages)
elif ext == "docx":
from docx import Document
doc = Document(io.BytesIO(file_content))
return "\n".join(p.text for p in doc.paragraphs)
else:
return file_content.decode("utf-8", errors="replace")
def chunk_text(text: str, chunk_size: int = 512, overlap: int = 50) -> list[str]:
text = text.strip()
if len(text) <= chunk_size:
return [text]
separators = ["\n\n", "\n", ". ", " ", ""]
return _split(text, separators, chunk_size, overlap)
def _split(text, separators, chunk_size, overlap):
if len(text) <= chunk_size:
return [text] if text.strip() else []
sep = next((s for s in separators if s in text), separators[-1])
parts = text.split(sep)
chunks, current = [], ""
for part in parts:
candidate = (current + sep + part) if current else part
if len(candidate) <= chunk_size:
current = candidate
else:
if current:
chunks.append(current)
if len(part) > chunk_size and len(separators) > 1:
chunks.extend(_split(part, separators[1:], chunk_size, overlap))
current = ""
else:
current = part
if current and current.strip():
chunks.append(current)
if overlap > 0 and len(chunks) > 1:
overlapped = [chunks[0]]
for i in range(1, len(chunks)):
overlapped.append(chunks[i-1][-overlap:] + " " + chunks[i])
chunks = overlapped
return [c.strip() for c in chunks if c.strip()]
Step 7: Embedding generation
Calls Ollama’s embedding API. The qwen3-embedding:8b model produces 4096-dimensional vectors — high enough resolution to distinguish between semantically similar but functionally different document sections.
# app/embedding.py
import requests
from app.config import settings
def get_embedding(text: str) -> list[float]:
resp = requests.post(
f"{settings.ollama_host}/api/embeddings",
json={"model": settings.embedding_model, "prompt": text.strip()},
timeout=120
)
resp.raise_for_status()
return resp.json()["embedding"]
def get_embeddings_batch(texts: list[str]) -> list[list[float]]:
embeddings = []
with requests.Session() as session:
for text in texts:
resp = session.post(
f"{settings.ollama_host}/api/embeddings",
json={"model": settings.embedding_model, "prompt": text.strip()},
timeout=120
)
resp.raise_for_status()
embeddings.append(resp.json()["embedding"])
return embeddings
Step 8: LanceDB vector store
LanceDB stores both the dense vectors and a Tantivy-based full-text search index in a single table. No external dependencies — it runs embedded inside the orchestrator process.
# app/vector_store.py
import logging
import pandas as pd
import pyarrow as pa
import lancedb
from app.config import settings
logger = logging.getLogger(__name__)
_db = None
_table = None
def get_table():
global _db, _table
if _table is None:
_db = lancedb.connect(settings.lancedb_uri)
if settings.table_name in _db.table_names():
_table = _db.open_table(settings.table_name)
else:
_table = None
return _table
def init_table(dimension: int):
global _db, _table
_db = lancedb.connect(settings.lancedb_uri)
if settings.table_name in _db.table_names():
_table = _db.open_table(settings.table_name)
logger.info("Opened table '%s' (%d rows)", settings.table_name, _table.count_rows())
else:
schema = pa.schema([
pa.field("chunk_id", pa.string()),
pa.field("text", pa.string()),
pa.field("source", pa.string()),
pa.field("vector", pa.list_(pa.float32(), list_size=dimension)),
])
_table = _db.create_table(settings.table_name, schema=schema)
logger.info("Created table '%s' with dim=%d", settings.table_name, dimension)
return _table
def add_records(records: list[dict]):
global _table
if _table is None:
raise RuntimeError("Table not initialized")
_table.add(records)
Step 9: Hybrid search with Reciprocal Rank Fusion
This is where the magic happens. Two searches run in parallel — dense vector similarity and sparse BM25 keyword matching — then the results are fused using RRF.
# app/hybrid_search.py
import numpy as np
from rank_bm25 import BM25Okapi
from app.config import settings
from app.vector_store import get_table
from app.embedding import get_embeddings_batch
class HybridSearchEngine:
def __init__(self):
self.all_chunks = []
self.bm25 = None
self.ready = False
def rebuild(self):
table = get_table()
if table is None:
return
df = table.to_pandas()
self.all_chunks = df.to_dict("records")
if self.all_chunks:
tokenized = [r["text"].lower().split() for r in self.all_chunks]
self.bm25 = BM25Okapi(tokenized)
self.ready = True
def vector_search(self, query_embedding, limit):
table = get_table()
return table.search(query_embedding).metric("cosine").limit(limit).to_list()
def keyword_search(self, query, limit):
if self.bm25 is None:
return []
scores = self.bm25.get_scores(query.lower().split())
top_idx = np.argsort(scores)[::-1][:limit]
return [{**self.all_chunks[i], "_bm25_score": float(scores[i])}
for i in top_idx if scores[i] > 0]
def hybrid_search(self, query, top_k=None):
top_k = top_k or settings.top_k
fetch_k = top_k * 3
query_embedding = get_embeddings_batch([query])[0]
vec_results = self.vector_search(query_embedding, fetch_k)
kw_results = self.keyword_search(query, fetch_k)
# RRF fusion
rrf_scores = {}
id_to_result = {}
for rank, r in enumerate(vec_results):
cid = r["chunk_id"]
rrf_scores[cid] = rrf_scores.get(cid, 0.0) + 1.0 / (settings.rrf_k + rank + 1)
id_to_result[cid] = r
for rank, r in enumerate(kw_results):
cid = r["chunk_id"]
rrf_scores[cid] = rrf_scores.get(cid, 0.0) + 1.0 / (settings.rrf_k + rank + 1)
if cid not in id_to_result:
id_to_result[cid] = r
sorted_ids = sorted(rrf_scores, key=rrf_scores.get, reverse=True)[:top_k]
final = []
for cid in sorted_ids:
entry = id_to_result[cid].copy()
entry["_rrf_score"] = round(rrf_scores[cid], 6)
final.append(entry)
return final
engine = HybridSearchEngine()
How RRF works:
RRF_score(d) = Σ 1 / (k + rank_i(d))
Where k = 60 (smoothing constant) and rank_i(d) is the document’s position in each result list. A chunk ranked #3 in vector search and #5 in keyword search gets:
score = 1/(60+3) + 1/(60+5) = 0.01587 + 0.01538 = 0.03125
This naturally favors documents that appear in both result lists — exactly what you want for hybrid retrieval.
Step 10: LLM client
The frontier model is called via OpenAI-compatible endpoint. This means you can point it at Ollama, vLLM, LiteLLM, OpenAI, OpenRouter, or any compatible proxy without changing application code.
# app/llm_client.py
from openai import OpenAI
from app.config import settings
client = OpenAI(
base_url=settings.frontier_url,
api_key=settings.frontier_api_key,
)
def generate_answer(query: str, context_chunks: list[str]) -> str:
context = "\n\n---\n\n".join(
f"[Source {i+1}]: {chunk}" for i, chunk in enumerate(context_chunks)
)
system_prompt = (
"You are a precise assistant. Answer using ONLY the provided context. "
"If the context doesn't contain enough information, say so clearly. "
"Cite sources by their [number] reference."
)
user_prompt = f"Context:\n\n{context}\n\n---\n\nQuestion: {query}\n\nAnswer:"
response = client.chat.completions.create(
model=settings.frontier_model,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
temperature=0.2,
max_tokens=2048,
)
return response.choices[0].message.content
Step 11: FastAPI application
The main application ties everything together: file upload, ingestion, hybrid query, and health checks.
# app/main.py
import time
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI, UploadFile, File, HTTPException
from pydantic import BaseModel
import boto3
from botocore.client import Config as BotoConfig
from app.config import settings
from app.document_processor import extract_text, chunk_text
from app.embedding import get_embeddings_batch, get_embedding
from app.vector_store import init_table, add_records
from app.hybrid_search import engine
from app.llm_client import generate_answer
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
s3 = boto3.client(
"s3",
endpoint_url=f"http://{settings.rustfs_endpoint}",
aws_access_key_id=settings.rustfs_access_key,
aws_secret_access_key=settings.rustfs_secret_key,
config=BotoConfig(signature_version="s3v4"),
region_name="us-east-1",
)
@asynccontextmanager
async def lifespan(app: FastAPI):
# Ensure bucket exists
try:
s3.head_bucket(Bucket=settings.rustfs_bucket)
except Exception:
s3.create_bucket(Bucket=settings.rustfs_bucket)
# Probe embedding dimension
dim = len(get_embedding("dimension probe"))
init_table(dim)
engine.rebuild()
logger.info("Ready. %d chunks indexed. Embedding dim=%d.", engine.chunk_count, dim)
yield
app = FastAPI(title="Hybrid RAG Orchestrator", lifespan=lifespan)
class QueryRequest(BaseModel):
query: str
top_k: int = settings.top_k
class QueryResponse(BaseModel):
answer: str
sources: list[dict]
timings: dict
@app.get("/health")
async def health():
return {
"status": "ok",
"documents": engine.document_count,
"chunks": engine.chunk_count,
"embedding_model": settings.embedding_model,
"llm_model": settings.frontier_model,
}
@app.post("/api/upload")
async def upload_file(file: UploadFile = File(...)):
data = await file.read()
s3.put_object(Bucket=settings.rustfs_bucket, Key=file.filename, Body=data)
return {"filename": file.filename, "size": len(data)}
@app.post("/api/ingest")
async def ingest():
objects = s3.list_objects_v2(Bucket=settings.rustfs_bucket).get("Contents", [])
total_chunks = 0
files_processed = 0
for obj in objects:
key = obj["Key"]
ext = key.rsplit(".", 1)[-1].lower()
if ext not in {"pdf", "txt", "md", "docx"}:
continue
raw = s3.get_object(Bucket=settings.rustfs_bucket, Key=key)["Body"].read()
text = extract_text(raw, key)
chunks = chunk_text(text, settings.chunk_size, settings.chunk_overlap)
if not chunks:
continue
embeddings = get_embeddings_batch(chunks)
records = [
{"chunk_id": f"{key}#{i}", "text": c, "source": key, "vector": e}
for i, (c, e) in enumerate(zip(chunks, embeddings))
]
add_records(records)
total_chunks += len(records)
files_processed += 1
engine.rebuild()
return {"files": files_processed, "chunks": total_chunks}
@app.post("/api/query", response_model=QueryResponse)
async def query(req: QueryRequest):
if engine.chunk_count == 0:
raise HTTPException(400, "No documents ingested. Upload and run /api/ingest first.")
t0 = time.time()
results = engine.hybrid_search(req.query, req.top_k)
search_ms = round((time.time() - t0) * 1000)
context_chunks = [r["text"] for r in results]
sources = [
{"source": r["source"], "text": r["text"][:200], "rrf_score": r.get("_rrf_score", 0)}
for r in results
]
t0 = time.time()
answer = generate_answer(req.query, context_chunks)
llm_ms = round((time.time() - t0) * 1000)
return QueryResponse(
answer=answer,
sources=sources,
timings={"search_ms": search_ms, "llm_ms": llm_ms, "total_ms": search_ms + llm_ms},
)
Step 12: Run it
# Start all services
docker compose up -d --build
# Watch the orchestrator logs (wait for "Ready" message)
docker compose logs -f rag-orchestrator
# Upload a document
curl -X POST http://localhost:8000/api/upload -F "file=@sop-cleanroom-01.txt"
# Trigger ingestion (chunk, embed, index)
curl -X POST http://localhost:8000/api/ingest
# Query
curl -X POST http://localhost:8000/api/query \
-H "Content-Type: application/json" \
-d '{"query": "What happens if CR-MODULE-99 reports a low temperature?"}'
Step 13: Verify with a test document
Create sop-cleanroom-01.txt:
SOP-CLEANROOM-01: Environmental Monitoring Parameters.
All cold room storage arrays designated under tier-1 biological compliance
must operate continuously at a threshold setting of 4.0°C. If sensor reading
array CR-MODULE-99 drops below 2.0°C, an absolute deviation event is
registered, and quality control management must be notified immediately.
Upload, ingest, then query with both exact-match terms and semantic phrasing:
# Exact keyword match test
curl -X POST http://localhost:8000/api/query \
-H "Content-Type: application/json" \
-d '{"query": "What is the threshold for CR-MODULE-99?"}'
# Semantic match test
curl -X POST http://localhost:8000/api/query \
-H "Content-Type: application/json" \
-d '{"query": "What happens if storage temperature drops too low?"}'
The first query relies heavily on BM25 to match the exact identifier “CR-MODULE-99.” The second relies on vector similarity to understand that “drops too low” means the same thing as “drops below 2.0°C.” Hybrid search handles both.
Tuning the retrieval weights
The DENSE_WEIGHT environment variable controls the balance:
| Value | Behavior | Best for |
|---|---|---|
0.3 |
70% keyword, 30% semantic | Documents with many IDs, codes, part numbers |
0.5 |
Equal weight | General-purpose mixed content |
0.7 |
70% semantic, 30% keyword | Narrative documents, policies, procedures |
1.0 |
Pure vector search | Conceptual Q&A where exact terms don’t matter |
0.0 |
Pure keyword search | Exact lookup by ID or code |
For quality documents (SOPs, CAPAs, deviations), start at 0.7 and adjust based on query patterns. If users frequently search by document ID or part number, drop to 0.5.
Access points
| Service | URL | Credentials |
|---|---|---|
| RAG API | http://localhost:8000/docs |
None (Swagger UI) |
| RustFS Console | http://localhost:9001 |
admin / admin123456 |
| Ollama API | http://localhost:11434 |
None |
What to avoid
Do not use fixed-size chunking. Cutting every 500 characters splits sentences in half and separates headings from their content. Use structure-aware chunking that respects paragraph and sentence boundaries.
Do not skip the BM25 layer. Vector-only RAG will confuse document IDs, part numbers, and regulatory references that differ by one character. Hybrid retrieval is not optional for regulated content.
Do not use a cloud embedding model for the initial indexing. The whole point of this architecture is data sovereignty. Your raw document text never leaves your network. The embedding model runs in Ollama on your infrastructure.
Do not run the frontier model on CPU for production. CPU inference on a 32B model produces one token every few seconds. For testing, it works. For anything users will actually interact with, you need a GPU or a hosted API endpoint.
The bottom line
This stack gives you a fully local embedding pipeline with state-of-the-art hybrid retrieval and frontier-model generation quality — all containerized in four Docker services. Your documents never leave your infrastructure. The retrieval is transparent and tunable. The frontier model is swappable via a single environment variable.
For quality documents in regulated environments, hybrid RAG is not a nice-to-have. It is the minimum viable retrieval architecture. Pure vector search will miss your SOP numbers. Pure keyword search will miss your conceptual queries. Hybrid catches both.
Research notes: [[hybrid-rag-docker-tutorial-analysis]]
Saram Consulting