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 — detailed DB comparisons
- Personal Search Architecture — the philosophy behind semantic search
- Digital Gardens — the content to search
- Hybrid Search — combining keywords and vectors
- Memory System Guide — building persistent AI memory