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:

LayerPurposeOptions
EmbeddingsConvert text to vectorsFastEmbed, sentence-transformers, Ollama
StorageIndex and query vectorsChroma, LanceDB, Qdrant, SQLite-VSS
InterfaceSearch UICLI, 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.

ModelDimensionsSizeSpeedQuality
all-MiniLM-L6-v238422MBFastGood enough
bge-small-en-v1.538433MBFastBetter
nomic-embed-text-v1.5768137MBMediumGreat
mxbai-embed-large-v11024335MBSlowBest

For notes under 50K documents, bge-small-en-v1.5 hits the sweet spot. Use nomic-embed-text if you need multilingual support.

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:

ToolBest forSetup
ChromaQuick prototypespip install chromadb
LanceDBLarge document setspip install lancedb
SQLite-VSSMinimal dependenciesSingle file, no server
QdrantProduction, filteringDocker 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

#!/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

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

  1. FastEmbed over sentence-transformers if you want lighter dependencies and faster cold starts.

  2. LanceDB for personal scale handles growth from 1K to 1M documents without architecture changes.

  3. SQLite-VSS for portable indexes when you need a single-file solution.

  4. Weekly cron indexing beats real-time sync for most PKM workflows.

  5. Hybrid search scoring: vector_score + (keyword_hits * 0.1) catches exact matches.

Next: Embedding Models for PKM