- Posted on
- • Artificial Intelligence
Artificial Intelligence Search on Linux
- Author
-
-
- User
- linuxbash
- Posts by this author
- Posts by this author
-
Artificial Intelligence Search on Linux: Find Meaning, Not Just Keywords
Ever spent 10 minutes grepping through notes or logs only to miss the exact thing you needed because you didn’t use the “right” word? Traditional search is literal—great at exact matches, terrible at meaning. AI-powered search changes that: it retrieves content by concept and intent. On Linux, you can run it locally, privately, and from Bash.
This article shows you how to:
Build a tiny local semantic search tool that runs entirely on your Linux box
Query it from Bash as easily as grep
Scale to a vector database if your corpus grows
Install everything via apt, dnf, or zypper
No cloud, no API keys—just your shell and open-source tools.
Why AI search on Linux?
Keyword search breaks on synonyms and phrasing. “ssh keys” vs “public key authentication” shouldn’t be two different searches.
Semantic search uses embeddings (vector representations of text) to measure conceptual similarity, not string overlap.
Privacy and control: running locally keeps your docs, logs, and code on your machine.
It’s fast. With a lightweight model and an efficient index, queries are interactive even on laptops.
What you’ll build
Step 1: A self-contained Python+FAISS indexer that converts your files into embeddings.
Step 2: A CLI query tool that returns the most semantically relevant snippets with file:line references.
Step 3: Optional: Scale to a vector DB (Qdrant) for large datasets or multi-user setups.
Real-world examples: Search notes, code, or logs by idea, not just words.
Install prerequisites
You’ll need Python 3, a compiler toolchain, and OpenBLAS (for fast math). The commands differ by distro:
Debian/Ubuntu (apt):
sudo apt update
sudo apt install -y python3 python3-venv python3-pip build-essential git curl jq libopenblas-dev
Fedora (dnf):
sudo dnf -y install python3 python3-pip gcc-c++ make git curl jq openblas-devel
openSUSE (zypper):
sudo zypper refresh
sudo zypper install -y python3 python3-pip gcc-c++ make git curl jq openblas-devel
Create and activate a virtual environment (recommended):
python3 -m venv ~/.venvs/aisearch
source ~/.venvs/aisearch/bin/activate
python3 -m pip install --upgrade pip
pip install "sentence-transformers<3" faiss-cpu rich tqdm qdrant-client
Notes:
The model we’ll use (all-MiniLM-L6-v2) runs well on CPU and produces 384‑dimensional embeddings.
If faiss-cpu wheel isn’t available for your arch, consider alternatives like annoy or use Docker for FAISS.
Step 1: Index your files semantically (local, offline)
Save this as ai_index.py:
#!/usr/bin/env python3
import argparse, os, json, sys, pathlib, math
from typing import List, Tuple
from tqdm import tqdm
import numpy as np
import faiss
from sentence_transformers import SentenceTransformer
DEFAULT_MODEL = "sentence-transformers/all-MiniLM-L6-v2"
EMBED_DIM = 384 # for MiniLM-L6-v2
SKIP_DIRS = {".git", ".hg", ".svn", "node_modules", "__pycache__", "target", "build", "dist", ".venv", "venv"}
ALLOW_EXT = {".md",".txt",".rst",".py",".sh",".c",".cpp",".h",".hpp",".go",".java",".js",".ts",".json",".yml",".yaml",".toml",".ini",".log",".conf",".sql",".rb",".rs",".lua",".php",".pl",".scala",".kt",".ipynb",".tex",".org"}
def iter_files(root: pathlib.Path):
for dirpath, dirnames, filenames in os.walk(root):
# prune
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS and not d.startswith(".cache")]
for f in filenames:
p = pathlib.Path(dirpath) / f
if p.suffix.lower() in ALLOW_EXT:
yield p
def chunk_lines(text: str, window: int = 6, step: int = 6):
lines = text.splitlines()
for i in range(0, len(lines), step):
chunk = lines[i:i+window]
if chunk:
yield i+1, "\n".join(chunk)
def build_index(root: pathlib.Path, outdir: pathlib.Path, model_name: str):
outdir.mkdir(parents=True, exist_ok=True)
model = SentenceTransformer(model_name)
vecs = []
metas = []
files = list(iter_files(root))
if not files:
print("No files found to index.", file=sys.stderr)
return
for path in tqdm(files, desc="Indexing files"):
try:
text = path.read_text(encoding="utf-8", errors="ignore")
except Exception:
continue
for start_line, chunk in chunk_lines(text, window=8, step=6):
if chunk.strip():
metas.append({"path": str(path), "line": start_line, "text": chunk[:500]})
vecs.append(chunk)
print(f"Encoding {len(vecs)} chunks...")
embeddings = model.encode(vecs, batch_size=64, show_progress_bar=True, convert_to_numpy=True)
# Normalize for cosine similarity and use Inner Product
faiss.normalize_L2(embeddings)
index = faiss.IndexFlatIP(EMBED_DIM)
index.add(embeddings)
faiss.write_index(index, str(outdir / "index.faiss"))
with open(outdir / "meta.jsonl", "w", encoding="utf-8") as f:
for m in metas:
f.write(json.dumps(m, ensure_ascii=False) + "\n")
with open(outdir / "model.json", "w", encoding="utf-8") as f:
json.dump({"model": model_name, "dim": EMBED_DIM}, f)
print(f"Index built: {len(metas)} chunks")
print(f"Saved to: {outdir}")
def main():
ap = argparse.ArgumentParser(description="Build a local semantic search index.")
ap.add_argument("root", help="Directory to index")
ap.add_argument("--out", default=".ai-index", help="Output directory for index files")
ap.add_argument("--model", default=DEFAULT_MODEL, help="SentenceTransformer model")
args = ap.parse_args()
root = pathlib.Path(args.root).expanduser().resolve()
outdir = pathlib.Path(args.out).expanduser().resolve()
build_index(root, outdir, args.model)
if __name__ == "__main__":
main()
Make it executable and run:
chmod +x ai_index.py
./ai_index.py ~/notes --out ~/.ai-search/notes-index
This walks your files, chunks them, encodes each chunk, and saves:
index.faiss: the vector index
meta.jsonl: file path, start line, and a snippet
model.json: model metadata
Step 2: Query from Bash like a pro
Save as ai_search.py:
#!/usr/bin/env python3
import argparse, json, pathlib, sys, textwrap
import numpy as np
import faiss
from sentence_transformers import SentenceTransformer
def load_meta(meta_path):
metas = []
with open(meta_path, "r", encoding="utf-8") as f:
for line in f:
metas.append(json.loads(line))
return metas
def main():
ap = argparse.ArgumentParser(description="Query a local semantic index")
ap.add_argument("query", help="Your search query in natural language")
ap.add_argument("--index", default="~/.ai-search/notes-index", help="Index directory")
ap.add_argument("-k", type=int, default=8, help="Top K results")
args = ap.parse_args()
index_dir = pathlib.Path(args.index).expanduser().resolve()
model_info = json.loads((index_dir / "model.json").read_text(encoding="utf-8"))
model = SentenceTransformer(model_info["model"])
index = faiss.read_index(str(index_dir / "index.faiss"))
metas = load_meta(index_dir / "meta.jsonl")
q = model.encode([args.query], convert_to_numpy=True)
faiss.normalize_L2(q)
D, I = index.search(q, args.k) # cosine similarity due to normalization + IP
for rank, (score, idx) in enumerate(zip(D[0], I[0]), start=1):
m = metas[int(idx)]
print(f"[{rank}] {m['path']}:{m['line']} score={float(score):.3f}")
print(textwrap.indent(m["text"].strip(), prefix=" "))
print()
if __name__ == "__main__":
main()
Try it:
./ai_search.py "how to rotate ssh keys" --index ~/.ai-search/notes-index
Optional: a tiny shell wrapper to jump to the best match in your editor:
ais() {
local idx="${1:-$HOME/.ai-search/notes-index}"
local q="${*:2}"
local first
first=$(./ai_search.py "$q" --index "$idx" -k 1 | awk 'NR==1 {print $2}')
if [ -n "$first" ]; then
local file="${first%%:*}"
local line="${first##*:}"
"${EDITOR:-vim}" "+${line}" "$file"
else
echo "No result"
fi
}
# usage: ais ~/.ai-search/notes-index "explain systemd service wantedby"
Step 3: Real-world uses you can drop in today
Search logs by cause, not code: “database connection flapping” can surface TLS or DNS errors you didn’t keyword for.
Discover code by intent: “function that retries HTTP with backoff” finds relevant utilities across languages.
Mine notes and docs: “generate SSH key pair” yields chunks mentioning ssh-keygen, config examples, and key formats.
Tip: Rebuild the index after large changes:
./ai_index.py ~/code --out ~/.ai-search/code-index
Step 4: Scale out with Qdrant (vector database)
If your corpus grows into millions of chunks or you want multi-user search, use a vector DB. Qdrant is fast, open-source, and easy to run on Linux.
Install Qdrant:
Debian/Ubuntu (apt):
# Add Qdrant repo and key
curl -fsSL https://deb.qdrant.tech/qdrant_pub.gpg | sudo gpg --dearmor -o /usr/share/keyrings/qdrant.gpg
echo "deb [signed-by=/usr/share/keyrings/qdrant.gpg] https://deb.qdrant.tech/ qdrant main" | sudo tee /etc/apt/sources.list.d/qdrant.list
sudo apt update
sudo apt install -y qdrant
Fedora (dnf):
sudo rpm --import https://rpm.qdrant.tech/qdrant.gpg
sudo dnf config-manager --add-repo https://rpm.qdrant.tech/qdrant.repo
sudo dnf install -y qdrant
openSUSE (zypper):
sudo rpm --import https://rpm.qdrant.tech/qdrant.gpg
sudo zypper ar https://rpm.qdrant.tech/qdrant.repo qdrant
sudo zypper refresh
sudo zypper install -y qdrant
Start the service:
sudo systemctl enable --now qdrant
# default listens on http://localhost:6333
Create a collection (384 dims for MiniLM, cosine distance):
curl -X PUT "http://localhost:6333/collections/docs" \
-H "Content-Type: application/json" \
-d '{"vectors":{"size":384,"distance":"Cosine"}}'
Ingest your documents with the same model you used locally. Save as qdrant_ingest.py:
#!/usr/bin/env python3
import argparse, pathlib, os
from typing import List
from sentence_transformers import SentenceTransformer
from qdrant_client import QdrantClient
from qdrant_client.models import VectorParams, Distance, PointStruct
from tqdm import tqdm
import numpy as np
ALLOW_EXT = {".md",".txt",".py",".sh",".log",".json",".yml",".yaml",".toml",".ini",".rst",".conf"}
def iter_files(root: pathlib.Path):
for dirpath, _, filenames in os.walk(root):
if any(s in dirpath for s in [".git","node_modules",".venv","__pycache__"]):
continue
for f in filenames:
p = pathlib.Path(dirpath) / f
if p.suffix.lower() in ALLOW_EXT:
yield p
def chunks(lines, window=8, step=6):
for i in range(0, len(lines), step):
chunk = lines[i:i+window]
if chunk:
yield i+1, "\n".join(chunk)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("root")
ap.add_argument("--collection", default="docs")
ap.add_argument("--host", default="localhost")
ap.add_argument("--port", type=int, default=6333)
ap.add_argument("--batch", type=int, default=256)
ap.add_argument("--model", default="sentence-transformers/all-MiniLM-L6-v2")
args = ap.parse_args()
model = SentenceTransformer(args.model)
client = QdrantClient(host=args.host, port=args.port)
# Ensure collection exists
client.recreate_collection(
collection_name=args.collection,
vectors_config=VectorParams(size=384, distance=Distance.COSINE)
)
points = []
pid = 0
for path in tqdm(list(iter_files(pathlib.Path(args.root)))):
text = path.read_text(encoding="utf-8", errors="ignore")
ls = text.splitlines()
for line, chunk in chunks(ls):
payload = {"path": str(path), "line": line, "text": chunk[:700]}
points.append((payload, chunk))
if len(points) >= args.batch:
payloads = [p[0] for p in points]
embs = model.encode([p[1] for p in points], convert_to_numpy=True)
# Qdrant expects raw (it will handle cosine), normalize optional
client.upsert(
collection_name=args.collection,
points=[PointStruct(id=pid+i, vector=embs[i].tolist(), payload=payloads[i]) for i in range(len(points))]
)
pid += len(points)
points = []
# flush remaining
if points:
payloads = [p[0] for p in points]
embs = model.encode([p[1] for p in points], convert_to_numpy=True)
client.upsert(
collection_name=args.collection,
points=[PointStruct(id=pid+i, vector=embs[i].tolist(), payload=payloads[i]) for i in range(len(points))]
)
if __name__ == "__main__":
main()
Run it:
python3 qdrant_ingest.py ~/notes --collection docs
Query Qdrant with a natural-language string:
QUERY='how to rotate ssh keys'
# Encode the query to a vector using the same model in a quick Python one-liner:
python3 - <<'PY'
from sentence_transformers import SentenceTransformer
m=SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")
import sys, json
import numpy as np
v = m.encode([sys.argv[1]], convert_to_numpy=True)[0].tolist()
print(json.dumps(v))
PY "$QUERY" > /tmp/qvec.json
# Search in Qdrant
curl -s "http://localhost:6333/collections/docs/points/search" \
-H 'Content-Type: application/json' \
-d @- <<EOF | jq
{ "vector": $(cat /tmp/qvec.json), "limit": 5 }
EOF
This returns payloads with path:line and snippets—ready for your editor or scripts.
Tips and gotchas
Reproducibility: Always encode and search with the same model. Switching models changes vector dimensions and meaning space.
Chunk size matters: Too small and you lose context; too big and results are vague. 6–10 lines is a good starting point for code and notes.
Performance: FAISS is fast; for very large corpora use HNSW/IVF indexes or offload to Qdrant.
Binary/noisy files: Use allowlists or .gitignore-style filters to keep your index clean.
Conclusion and next steps
You now have an AI-powered search that:
Runs locally on Linux
Finds concepts, not just strings
Integrates with your shell and editor
Scales to a vector database when you need it
Next steps:
Wire
ai_search.pyinto your prompt or fzf workflow.Maintain multiple indexes (notes, code, logs) under ~/.ai-search.
Experiment with models (e.g., bge-small, e5-small) for your domain.
Add reranking with a cross-encoder for even more precise top results.
Have an idea you want to search better? Point the indexer at that directory and try a “how do I…” query. Your future self will thank you.