- Posted on
- • Artificial Intelligence
Artificial Intelligence Desktop Search
- Author
-
-
- User
- linuxbash
- Posts by this author
- Posts by this author
-
AI Desktop Search on Linux: Turn your terminal into a semantic search engine
You’ve got 30,000+ files across notes, PDFs, logs, code, and wikis. grep is great—until you can’t remember the exact words. AI Desktop Search bridges that gap: instead of exact matches, you get semantic matches and answers, even when your query is fuzzy.
In this post you’ll:
Understand why AI search on your Linux desktop is worth it
Set up a quick “LLM-reranked grep” you can use today
Build a local, private embeddings index (RAG) for true semantic search
Keep it fresh with a background indexer
Get package-manager-specific install commands (apt, dnf, zypper)
Why AI desktop search is worth it
Natural language beats filenames: “diagram about zero-downtime deploy” should find that
blue-green.md, even if you never wrote “zero-downtime”.Summaries over snippets: LLMs can answer, not just list matches.
Cross-format recall: PDFs, DOCX, HTML, Markdown—one search.
Private and offline: Modern local models and embeddings run on your machine.
Prerequisites (install via your package manager)
You’ll use a mix of familiar CLI tools, plus an LLM runtime (Ollama) and/or Python libs for embeddings.
Debian/Ubuntu (apt):
sudo apt update sudo apt install -y ripgrep fd-find jq curl python3 python3-venv python3-pip # On Debian/Ubuntu the fd binary is named "fdfind"; optional convenience alias: echo 'alias fd=fdfind' >> ~/.bashrcFedora (dnf):
sudo dnf install -y ripgrep fd-find jq curl python3 python3-pip python3-virtualenv # On Fedora the binary is usually "fd"openSUSE (zypper):
sudo zypper install -y ripgrep fd jq curl python3 python3-pip python3-virtualenv
Install Ollama (local LLM runtime, works offline once the model is downloaded):
curl -fsSL https://ollama.com/install.sh | sh
# Pull a small, capable model (you can swap for your favorite):
ollama pull llama3
Tip: If you’ll build the embeddings index below, you’ll also install Python packages via a virtual environment.
Option A (5–10 minutes): LLM‑reranked grep for smarter results today
This approach uses ripgrep to grab candidate snippets, then asks a local LLM (via Ollama) to re-rank them by meaning and explain why.
1) Save this script as ai-search.sh and make it executable:
#!/usr/bin/env bash
set -euo pipefail
MODEL="${MODEL:-llama3}"
if [ $# -lt 1 ]; then
echo "Usage: $0 <natural-language query> [search_root=.]" >&2
exit 1
fi
QUERY="$1"
ROOT="${2:-.}"
TMPDIR="$(mktemp -d)"
SNIPS="$TMPDIR/snippets.txt"
# Turn the query into a reasonable OR-pattern for rg (basic candidate recall)
# Ignore very short words to reduce noise.
PATTERN="$(printf '%s\n' "$QUERY" | tr ' ' '\n' | awk 'length>2' | paste -sd'|' -)"
if [ -z "$PATTERN" ]; then
PATTERN="$QUERY"
fi
# Collect up to ~200 candidate snippets with some context
rg -n --no-heading --hidden -S -i \
-g '!.git' -g '!node_modules' -g '!.venv' \
-C1 --max-filesize 2M --max-count 200 \
-e "$PATTERN" "$ROOT" | awk 'NF' | nl -ba -w2 -s' ' > "$SNIPS"
if [ ! -s "$SNIPS" ]; then
echo "No candidate matches found with ripgrep." >&2
exit 0
fi
PROMPT=$(cat <<'EOF'
You are a local search results re-ranker for code and documents.
Given a user query and a list of snippets in the format:
[N] path:line: context-before
matched line
context-after
Return the 10 most relevant entries ranked by semantic relevance to the query.
Output plain text lines exactly in this format (one per line):
rank. [N] path:line — brief reason
EOF
)
echo "Re-ranking with model: $MODEL" >&2
ollama run "$MODEL" <<EOF
$PROMPT
User query:
$QUERY
Snippets:
$(cat "$SNIPS")
EOF
2) Use it:
chmod +x ai-search.sh
./ai-search.sh "how do we rotate database credentials" ~/work/infra
What you get: a human-friendly, meaning-aware ranking and “why” reasoning—using your CPU/GPU only, no cloud required.
Limitations: This still relies on ripgrep for first-pass recall, so it shines when you remember a few terms. For true fuzzy/semantic matches across everything, build the embeddings index below.
Option B (30–45 minutes): Local embeddings index for true semantic search (RAG)
This builds a private vector index of your files. Queries are turned into embeddings and matched by meaning, not exact words—no network required.
1) Create and activate a Python virtual environment:
Debian/Ubuntu:
python3 -m venv ~/.venvs/aisearch source ~/.venvs/aisearch/bin/activate pip install --upgrade pipFedora:
python3 -m venv ~/.venvs/aisearch # if missing, install python3-virtualenv source ~/.venvs/aisearch/bin/activate pip install --upgrade pipopenSUSE:
python3 -m venv ~/.venvs/aisearch # if missing, install python3-virtualenv source ~/.venvs/aisearch/bin/activate pip install --upgrade pip
2) Install Python packages:
pip install sentence-transformers faiss-cpu pypdf beautifulsoup4 lxml python-docx tqdm
3) Save this indexer as index_dir.py:
#!/usr/bin/env python3
import argparse, os, json, re, sys
from pathlib import Path
from tqdm import tqdm
from sentence_transformers import SentenceTransformer
import faiss
# Lightweight extractors
def read_text(path):
try:
return Path(path).read_text(encoding="utf-8", errors="ignore")
except Exception:
return ""
def read_pdf(path):
from pypdf import PdfReader
try:
text = []
r = PdfReader(path)
for page in r.pages:
text.append(page.extract_text() or "")
return "\n".join(text)
except Exception:
return ""
def read_docx(path):
try:
from docx import Document
doc = Document(path)
return "\n".join(p.text for p in doc.paragraphs)
except Exception:
return ""
def read_html(path):
try:
from bs4 import BeautifulSoup
t = Path(path).read_text(encoding="utf-8", errors="ignore")
return BeautifulSoup(t, "lxml").get_text(separator="\n")
except Exception:
return ""
def extract_text(path):
ext = Path(path).suffix.lower()
if ext in [".txt", ".md", ".rst", ".log", ".cfg", ".ini", ".yaml", ".yml", ".json", ".py", ".sh", ".c", ".cpp", ".js", ".ts", ".java", ".go", ".rb", ".rs", ".toml"]:
return read_text(path)
if ext == ".pdf":
return read_pdf(path)
if ext in [".htm", ".html"]:
return read_html(path)
if ext == ".docx":
return read_docx(path)
return "" # skip others by default
def chunk_text(text, size=800, overlap=200):
text = re.sub(r"\s+\n", "\n", text)
text = re.sub(r"\n{3,}", "\n\n", text)
if len(text) <= size:
return [text]
chunks = []
start = 0
while start < len(text):
end = min(len(text), start + size)
chunks.append(text[start:end])
if end == len(text):
break
start = end - overlap
return chunks
def main():
ap = argparse.ArgumentParser(description="Build a local semantic index (FAISS) for a directory")
ap.add_argument("root", help="Directory to index")
ap.add_argument("--index", default="index.faiss", help="FAISS index output path")
ap.add_argument("--meta", default="meta.jsonl", help="Metadata JSONL output")
ap.add_argument("--model", default="sentence-transformers/all-MiniLM-L6-v2", help="Sentence-Transformers model")
ap.add_argument("--include", nargs="*", default=None, help="Glob(s) to include, e.g. **/*.md **/*.pdf")
ap.add_argument("--exclude", nargs="*", default=["**/.git/**", "**/node_modules/**", "**/.venv/**"], help="Glob(s) to exclude")
args = ap.parse_args()
root = Path(args.root).expanduser().resolve()
files = []
# Walk and match includes/excludes
if args.include:
for pat in args.include:
files.extend(root.glob(pat))
files = [p for p in files if p.is_file()]
else:
for p, _, fns in os.walk(root):
for fn in fns:
files.append(Path(p) / fn)
# Apply excludes
excl = [root / e for e in []] # placeholder
exglobs = [root.as_posix() + "/" + g for g in []] # not used
# Simpler: filter with fnmatch on posix paths
import fnmatch
cleaned = []
for f in files:
posix = f.as_posix()
skip = False
for g in args.exclude:
if fnmatch.fnmatch(posix, (root.as_posix().rstrip("/") + "/" + g.lstrip("/"))):
skip = True
break
if not skip:
cleaned.append(f)
files = cleaned
model = SentenceTransformer(args.model)
embeddings = []
meta = []
print(f"Scanning {len(files)} files...")
for path in tqdm(files):
txt = extract_text(path)
if not txt or len(txt.strip()) < 50:
continue
chunks = chunk_text(txt)
for i, ch in enumerate(chunks):
meta.append({
"path": str(path),
"chunk": i,
"preview": ch[:200].replace("\n", " "),
})
embeddings.append(ch)
if not embeddings:
print("No text extracted. Check your include/exclude patterns.", file=sys.stderr)
sys.exit(1)
print(f"Encoding {len(embeddings)} chunks with {args.model}...")
vecs = model.encode(embeddings, normalize_embeddings=True, show_progress_bar=True)
dim = vecs.shape[1]
index = faiss.IndexFlatIP(dim) # cosine via normalized vectors
index.add(vecs)
faiss.write_index(index, args.index)
with open(args.meta, "w", encoding="utf-8") as f:
for m in meta:
f.write(json.dumps(m, ensure_ascii=False) + "\n")
print(f"Wrote {args.index} and {args.meta}")
if __name__ == "__main__":
main()
4) Save this searcher as search.py:
#!/usr/bin/env python3
import argparse, json
from pathlib import Path
from sentence_transformers import SentenceTransformer
import faiss
def load_meta(path):
out = []
with open(path, "r", encoding="utf-8") as f:
for line in f:
out.append(json.loads(line))
return out
def main():
ap = argparse.ArgumentParser(description="Semantic search over a local FAISS index")
ap.add_argument("query")
ap.add_argument("--index", default="index.faiss")
ap.add_argument("--meta", default="meta.jsonl")
ap.add_argument("--model", default="sentence-transformers/all-MiniLM-L6-v2")
ap.add_argument("-k", type=int, default=10)
args = ap.parse_args()
index = faiss.read_index(args.index)
meta = load_meta(args.meta)
model = SentenceTransformer(args.model)
q = model.encode([args.query], normalize_embeddings=True)
D, I = index.search(q, args.k)
print(f"Top {args.k} results for: {args.query}\n")
for rank, (score, idx) in enumerate(zip(D[0], I[0]), 1):
m = meta[int(idx)]
print(f"{rank}. {m['path']} [chunk {m['chunk']}] score={score:.3f}")
print(f" {m['preview']}\n")
if __name__ == "__main__":
main()
5) Build the index (examples):
# Index everything under ~/docs and ~/work/code
python3 index_dir.py ~/docs --index docs.faiss --meta docs.jsonl --include '**/*.md' '**/*.pdf' '**/*.txt' '**/*.html' '**/*.docx'
python3 index_dir.py ~/work/code --index code.faiss --meta code.jsonl --include '**/*.py' '**/*.sh' '**/*.js' '**/*.ts' '**/*.go' '**/*.java' '**/*.rs' '**/*.c' '**/*.cpp'
# Or build a single merged index (simpler to start):
python3 index_dir.py ~/ --index desktop.faiss --meta desktop.jsonl --exclude '**/.git/**' '**/node_modules/**' '**/.venv/**'
6) Search it:
python3 search.py "how do we rotate database credentials" --index desktop.faiss --meta desktop.jsonl -k 10
python3 search.py "design doc about zero downtime deployments" --index desktop.faiss --meta desktop.jsonl -k 10
Optional: After retrieving top K chunks, you can feed them to your local model for a proper answer:
# Example: answer from top 5 chunks with Ollama
python3 search.py "what is our staging SSO flow?" --index desktop.faiss --meta desktop.jsonl -k 5 \
| sed -n 's/^ //p' > context.txt
ollama run llama3 <<'EOF'
You are a helpful assistant. Use the following context to answer the user's question.
Context:
<<<
$(cat context.txt)
>>>
Question: what is our staging SSO flow?
Answer concisely and cite file paths when relevant.
EOF
Keep it fresh: automate re-indexing
Use systemd user timers or cron to re-run the indexer nightly.
- systemd (user):
# ~/.config/systemd/user/aisearch-index.service
[Unit]
Description=Rebuild AI search index
[Service]
Type=oneshot
WorkingDirectory=%h
ExecStart=%h/.venvs/aisearch/bin/python3 %h/index_dir.py %h --index %h/desktop.faiss --meta %h/desktop.jsonl --exclude **/.git/** **/node_modules/** **/.venv/**
# ~/.config/systemd/user/aisearch-index.timer
[Unit]
Description=Run AI indexer nightly
[Timer]
OnCalendar=03:15
Persistent=true
[Install]
WantedBy=timers.target
Enable it:
systemctl --user daemon-reload
systemctl --user enable --now aisearch-index.timer
- cron (alternative):
15 3 * * * source $HOME/.venvs/aisearch/bin/activate && python3 $HOME/index_dir.py $HOME --index $HOME/desktop.faiss --meta $HOME/desktop.jsonl --exclude '**/.git/**' '**/node_modules/**' '**/.venv/**' >/dev/null 2>&1
Real-world usage ideas
Search across runbooks and tickets: “incident response checklist for TLS expiry”
Codebase Q&A: “where do we back off on 429?” across multiple languages
Compliance and audits: “documents mentioning vendor risk assessment”
Personal knowledge base: “notes about qemu bridged networking how-to”
Conclusion and next steps
Start with Option A (LLM‑reranked grep) for immediate wins in your terminal. When you’re ready for true fuzzy recall, build Option B’s embeddings index—private, fast, and offline. From there you can:
Wire the top‑K chunks into Ollama for full Q&A answers
Add more file types to the extractor
Schedule nightly indexing
Swap to a larger embedding model for better recall
If you want help tuning chunk sizes, adding OCR for images/scans, or integrating with fzf/tmux, ask and I’ll show you how to extend this setup.