Personal Search Tools
Table of content
You have notes scattered across folders. Keyword search fails when you can’t remember exact words. Semantic search finds notes by meaning instead.
This guide covers the complete toolkit: embedding models that convert text to vectors, vector stores that index them, and interfaces that make search usable. All local-first, all open source.
The Stack
Personal semantic search needs three components:
| Layer | Purpose | Options |
|---|---|---|
| Embeddings | Convert text to vectors | FastEmbed, sentence-transformers, Ollama |
| Storage | Index and query vectors | Chroma, LanceDB, Qdrant, SQLite-VSS |
| Interface | Search UI | CLI, web app, Alfred/Raycast |
Pick one from each. They all interoperate.
Embedding Models
Your embedding model determines search quality. Bigger models find subtler connections. Smaller models run faster.
| Model | Dimensions | Size | Speed | Quality |
|---|---|---|---|---|
all-MiniLM-L6-v2 | 384 | 22MB | Fast | Good enough |
bge-small-en-v1.5 | 384 | 33MB | Fast | Better |
nomic-embed-text-v1.5 | 768 | 137MB | Medium | Great |
mxbai-embed-large-v1 | 1024 | 335MB | Slow | Best |
For notes under 50K documents, bge-small-en-v1.5 hits the sweet spot. Use nomic-embed-text if you need multilingual support.
FastEmbed (Recommended)
Qdrant’s lightweight embedding library. No PyTorch dependency, runs on CPU, supports ONNX quantization.
# pip install fastembed
from fastembed import TextEmbedding
model = TextEmbedding("BAAI/bge-small-en-v1.5")
embeddings = list(model.embed(["My meeting notes", "Garden project ideas"]))
# Returns list of numpy arrays
FastEmbed downloads models on first use (~30MB for bge-small). Subsequent runs use cached models.
sentence-transformers (More Models)
The standard library. More model choices, but heavier dependencies.
# pip install sentence-transformers
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("BAAI/bge-small-en-v1.5")
embeddings = model.encode(["My meeting notes", "Garden project ideas"])
Ollama (Unified Runtime)
If you already run Ollama for LLMs, use it for embeddings too.
ollama pull nomic-embed-text
import ollama
response = ollama.embeddings(
model="nomic-embed-text",
prompt="My meeting notes"
)
vector = response["embedding"]
One runtime for both chat and embeddings. Simpler ops.
Vector Storage Comparison
See Vector Databases for Personal RAG for the full breakdown. Quick summary for personal search:
| Tool | Best for | Setup |
|---|---|---|
| Chroma | Quick prototypes | pip install chromadb |
| LanceDB | Large document sets | pip install lancedb |
| SQLite-VSS | Minimal dependencies | Single file, no server |
| Qdrant | Production, filtering | Docker or binary |
LanceDB (Underrated)
Serverless, embedded, handles millions of vectors. Built on Apache Arrow for fast scans.
# pip install lancedb
import lancedb
from lancedb.pydantic import LanceModel, Vector
from fastembed import TextEmbedding
embedder = TextEmbedding("BAAI/bge-small-en-v1.5")
class Note(LanceModel):
text: str
path: str
vector: Vector(384)
db = lancedb.connect("./notes.lance")
table = db.create_table("notes", schema=Note)
# Add notes
notes = [
{"text": "Meeting with Alex about API design", "path": "notes/meetings/2024-01.md"},
{"text": "Garden layout sketches", "path": "notes/projects/garden.md"},
]
for note in notes:
vec = list(embedder.embed([note["text"]]))[0]
note["vector"] = vec
table.add(notes)
# Search
query_vec = list(embedder.embed(["API discussions"]))[0]
results = table.search(query_vec).limit(5).to_list()
LanceDB stores everything in ./notes.lance/. No server. Copy the folder to move your index.
SQLite-VSS (Zero Dependencies)
If you want search in a single Python file without Docker:
# pip install sqlite-vss sentence-transformers
import sqlite3
import sqlite_vss
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("all-MiniLM-L6-v2")
conn = sqlite3.connect("notes.db")
conn.enable_load_extension(True)
sqlite_vss.load(conn)
conn.execute("""
CREATE VIRTUAL TABLE IF NOT EXISTS notes_vss USING vss0(embedding(384))
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY,
text TEXT,
path TEXT
)
""")
# Index
def add_note(text, path):
vec = model.encode(text)
cur = conn.execute("INSERT INTO notes (text, path) VALUES (?, ?)", (text, path))
conn.execute("INSERT INTO notes_vss (rowid, embedding) VALUES (?, ?)",
(cur.lastrowid, vec.tobytes()))
conn.commit()
# Search
def search(query, limit=5):
vec = model.encode(query)
rows = conn.execute("""
SELECT notes.text, notes.path, vss.distance
FROM notes_vss vss
JOIN notes ON notes.id = vss.rowid
WHERE vss_search(vss.embedding, ?)
LIMIT ?
""", (vec.tobytes(), limit)).fetchall()
return rows
Complete Indexing Pipeline
Here’s a full indexer for markdown notes:
#!/usr/bin/env python3
"""Index markdown notes for semantic search."""
import os
from pathlib import Path
from fastembed import TextEmbedding
import lancedb
from lancedb.pydantic import LanceModel, Vector
NOTES_DIR = Path.home() / "notes"
DB_PATH = Path.home() / ".local/share/note-search"
class Note(LanceModel):
text: str
title: str
path: str
vector: Vector(384)
def extract_notes(directory: Path):
"""Yield (text, title, path) from markdown files."""
for md_file in directory.rglob("*.md"):
content = md_file.read_text()
title = md_file.stem
# Extract title from frontmatter if present
if content.startswith("---"):
lines = content.split("\n")
for line in lines[1:]:
if line.startswith("title:"):
title = line.split(":", 1)[1].strip().strip('"\'')
break
if line == "---":
break
yield content, title, str(md_file)
def build_index():
embedder = TextEmbedding("BAAI/bge-small-en-v1.5")
db = lancedb.connect(str(DB_PATH))
# Drop and recreate
if "notes" in db.table_names():
db.drop_table("notes")
notes = []
texts = []
for text, title, path in extract_notes(NOTES_DIR):
notes.append({"text": text, "title": title, "path": path})
texts.append(text)
print(f"Embedding {len(texts)} notes...")
vectors = list(embedder.embed(texts))
for note, vec in zip(notes, vectors):
note["vector"] = vec
table = db.create_table("notes", data=notes, schema=Note)
print(f"Indexed {len(notes)} notes to {DB_PATH}")
if __name__ == "__main__":
build_index()
Run weekly via cron or after note changes.
Search Interfaces
CLI Search
#!/usr/bin/env python3
"""Search notes from command line."""
import sys
from fastembed import TextEmbedding
import lancedb
DB_PATH = "~/.local/share/note-search"
def search(query: str, limit: int = 10):
embedder = TextEmbedding("BAAI/bge-small-en-v1.5")
db = lancedb.connect(DB_PATH)
table = db.open_table("notes")
vec = list(embedder.embed([query]))[0]
results = table.search(vec).limit(limit).to_list()
for r in results:
score = 1 - r["_distance"] # Convert distance to similarity
print(f"{score:.2f} | {r['title']}")
print(f" {r['path']}")
print()
if __name__ == "__main__":
search(" ".join(sys.argv[1:]))
$ ./search.py "API design decisions"
0.82 | api-versioning-notes
/home/user/notes/tech/api-versioning-notes.md
0.71 | meeting-alex-march
/home/user/notes/meetings/meeting-alex-march.md
Raycast/Alfred Integration
Wrap the CLI in a script that outputs JSON for your launcher:
import json
results = search(query)
items = [
{
"title": r["title"],
"subtitle": r["path"],
"arg": r["path"], # Opens file on select
}
for r in results
]
print(json.dumps({"items": items}))
Web UI with Datasette
Datasette gives you instant web search over SQLite:
pip install datasette datasette-vss
datasette notes.db --open
Hybrid Search
Pure vector search misses exact matches. Combine with keywords:
def hybrid_search(query: str, limit: int = 10):
# Vector search
vec = embedder.embed([query])[0]
vector_results = table.search(vec).limit(limit * 2).to_list()
# Keyword filter
keywords = query.lower().split()
final = []
for r in vector_results:
text_lower = r["text"].lower()
keyword_hits = sum(1 for k in keywords if k in text_lower)
combined_score = (1 - r["_distance"]) + (keyword_hits * 0.1)
final.append((combined_score, r))
final.sort(reverse=True, key=lambda x: x[0])
return [r for _, r in final[:limit]]
See Hybrid Search for more sophisticated approaches.
What You Can Steal
FastEmbed over sentence-transformers if you want lighter dependencies and faster cold starts.
LanceDB for personal scale handles growth from 1K to 1M documents without architecture changes.
SQLite-VSS for portable indexes when you need a single-file solution.
Weekly cron indexing beats real-time sync for most PKM workflows.
Hybrid search scoring:
vector_score + (keyword_hits * 0.1)catches exact matches.
Related
- Vector Databases for Personal RAG for detailed DB comparisons
- Personal Search Architecture for the philosophy
- Digital Gardens for the content to search
Next: Embedding Models for PKM
Get updates
New guides, workflows, and AI patterns. No spam.
Thank you! You're on the list.