- Posted on
- • Artificial Intelligence
Hybrid Search Explained
- Author
-
-
- User
- linuxbash
- Posts by this author
- Posts by this author
-
Hybrid Search Explained (for Bash-first Linux Users)
If you’ve ever grepped for an error, only to learn later the system logged a “throttle” instead of your expected “rate limit,” you’ve met the limits of keyword search. Pure lexical tools (grep, ripgrep) are precise and fast, but they don’t understand meaning. Pure semantic tools (embeddings/vector search) understand meaning, but can miss exact filters and syntax. Hybrid search blends both: it keeps the precision of keywords and the intelligence of semantics—so you find the right thing faster.
This post explains hybrid search in plain terms, why it’s useful on the command line, and gives you a minimal, reproducible setup that combines ripgrep with a tiny local vector index built in Python. You’ll get actionable steps, examples, and scripts you can drop into your workflow.
What is hybrid search?
Lexical search (e.g.,
grep,ripgrep, BM25 in full‑text engines) matches exact tokens and patterns. It’s unbeatable for precision and filters (file globs, regex, line anchors).Semantic search (vector/embedding-based) maps text to high-dimensional vectors. Similar meanings land close together. It excels at recall when wording varies (synonyms, paraphrases, typos).
Hybrid search scores documents with both approaches and fuses the scores. Typical strategies:
- Weighted sum:
score = α * lexical + (1-α) * semantic - Two-stage: lexical filter/top‑K → semantic re-rank
- Reciprocal rank fusion: combine ranks from both lists
- Weighted sum:
Result: you still get exact hits for “OOM” while also surfacing “out of memory” mentions—and you can sort or filter as you like.
Why it matters on the CLI
Logs and traces: “auth failed” vs “authentication error” vs “login denied”
Code search: “rate limit” vs “throttle”, “cache bust” vs “invalidate”
Docs/notes/Wikis: phrasing drift over time, across teams and vendors
Hybrid search tightens feedback loops: fewer missed hits, fewer yak-shaves, faster root cause and code spelunking.
Prereqs: install the basics
We’ll use ripgrep for lexical search and a small Python script for vector search.
Debian/Ubuntu (apt):
sudo apt update && sudo apt install -y ripgrep jq curl python3 python3-pip python3-venvFedora/RHEL/CentOS (dnf):
sudo dnf install -y ripgrep jq curl python3 python3-pip python3-virtualenvopenSUSE (zypper):
sudo zypper install -y ripgrep jq curl python3 python3-pip python3-virtualenv
Note: On some distros, python3 -m venv works without an extra package. If python3-virtualenv or python3-venv isn’t found, skip it.
Create and activate a venv, then install Python deps:
Create venv:
python3 -m venv .venv && . .venv/bin/activateUpgrade pip:
pip install --upgrade pipInstall libs:
pip install sentence-transformers faiss-cpu numpy
Minimal hybrid search in 3 steps
We’ll index one vector per file (simple and good enough to get started). Then we’ll combine semantic scores with ripgrep hit counts.
1) Prepare a folder of text to search
- Example:
docs/with.md,.txt, or code files. You can index any UTF‑8 file.
2) Build a tiny vector index (Python)
- Save this as
embed.py:
import sys, os, numpy as np
from sentence_transformers import SentenceTransformer
if len(sys.argv) < 2:
print("Usage: python3 embed.py <root_dir>")
sys.exit(1)
root = sys.argv[1]
model = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")
paths, texts = [], []
for base, _, files in os.walk(root):
for f in files:
p = os.path.join(base, f)
try:
with open(p, "r", encoding="utf-8", errors="ignore") as h:
txt = h.read().strip()
if txt:
paths.append(p)
texts.append(txt)
except Exception:
pass
emb = model.encode(texts, normalize_embeddings=True, show_progress_bar=True)
np.save("emb.npy", emb)
with open("paths.txt", "w", encoding="utf-8") as o:
o.write("\n".join(paths))
print(f"Indexed {len(paths)} files → emb.npy, paths.txt")
- Run it:
python3 embed.py docs/
3) Query semantically and fuse with ripgrep
- Save this as
search.py(semantic scoring; prints “path score” for all files):
import sys, numpy as np
from sentence_transformers import SentenceTransformer
if len(sys.argv) < 2:
print("Usage: python3 search.py \"your query\" [topK]")
sys.exit(1)
q = sys.argv[1]
topK = int(sys.argv[2]) if len(sys.argv) > 2 else 200
E = np.load("emb.npy")
paths = [p.strip() for p in open("paths.txt", encoding="utf-8")]
model = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")
qv = model.encode([q], normalize_embeddings=True)[0]
scores = (E @ qv) # cosine similarity because both are normalized
idx = np.argsort(-scores)[:min(topK, len(paths))]
for i in idx:
print(f"{paths[i]} {scores[i]:.6f}")
Run semantic search:
python3 search.py "rate limit burst throttle" 500 > vec.txtRun lexical search over the same tree and normalize counts:
- Get counts per file:
rg -n -S -c "rate limit|throttle" docs/ > raw_lex.txt - Convert to “path count”:
awk -F: '{print $1" "$2}' raw_lex.txt | sort > lex_counts.txt - Compute max and normalize to [0,1]:
- Max:
max=$(awk '{print $2}' lex_counts.txt | sort -nr | head -1) - Normalize:
awk -v m=$max '{if(m>0){printf "%s %.6f\n",$1,$2/m}else{printf "%s %.6f\n",$1,0}}' lex_counts.txt > lex.txt
- Get counts per file:
Fuse scores with a weighted sum (α = lexical weight):
- Set weight:
alpha=0.5 - Join by path and compute fused score:
join -j 1 <(sort vec.txt) <(sort lex.txt) | awk -v a=$alpha '{printf "%s %.6f\n",$1, a*$3 + (1-a)*$2}' | sort -k2,2nr | head -20
- Set weight:
Tip: join expects both files sorted on the join column (the path). The output prints the top 20 files by hybrid score.
Real-world examples
Logs: Query
oom|out of memory|killed process. Lexical finds exact OOM lines; semantic also bubbles up lines saying “memory pressure” or “allocator failed” that lacked your exact tokens.Code search: Query
throttle rate limit backoff. Lexical catches exact “rate_limit” identifiers; semantic surfaces code/comments that say “debounce,” “cooldown,” or “quota” even without the exact words.Docs/Runbooks: Query
rotate credentials expired token. Results include exact “rotate keys” plus semantically similar guidance (“refresh secret,” “renew auth”).
4 actionable tips for better results
1) Start with two-stage retrieval for speed: use ripgrep to get the top 1,000 candidate files, then run search.py only on those. It’s cheap and often outperforms either method alone.
Example filter: rg -l -S "keyword1|keyword2" docs/ | head -1000 > cands.txt then adapt embed.py/search.py to load only cands.txt.
2) Tune α: if you’re missing exact token hits, increase alpha (e.g., 0.7). If you’re missing paraphrases, decrease it (e.g., 0.3).
3) Chunk long files: very large files benefit from paragraph- or section-level embeddings. Modify embed.py to split on blank lines so search.py returns more pinpointed hits.
4) Keep indexes fresh: rerun embed.py in CI or a cron job after content changes. Store emb.npy/paths.txt alongside your docs to speed bootstrapping.
Troubleshooting
No
faiss-cpuon your arch: trypip install faiss-cpu==1.7.4or skip Faiss entirely (we used plain NumPy dot products above, so Faiss isn’t strictly required in this minimal example).Model download slow: prefetch with
python3 -c "from sentence_transformers import SentenceTransformer; SentenceTransformer('sentence-transformers/all-MiniLM-L6-v2')"on a box with better bandwidth and cache~/.cacheas needed.Encoding errors: the scripts open files with
errors="ignore". For binary files, they’re skipped; consider filtering withfindglobs if you have many binaries.
Conclusion and next steps
Hybrid search combines the best of both worlds: exactness from ripgrep, understanding from embeddings. You now have a minimal, composable Bash-friendly workflow you can run anywhere:
Build the vector index once (
python3 embed.py docs/)Query semantically (
python3 search.py "your query")Fuse with lexical hits (
rg ...+ thejoin/awkline)
From here, you can:
Add chunking to improve granularity.
Cache per-repo indexes and wire this into a shell function.
Scale up to a server with a native hybrid engine (e.g., systems that support BM25+vector and RRF), once you outgrow local scripts.
Your move: point this setup at your logs or docs, pick a query that’s historically painful, and see what hybrid search surfaces that pure grepping missed.