Intermediate16 min read

Build a persistent text search index

Index local text files in SQLite, retrieve exact terms with source locations, and refresh the index when files change.

On this page

Make a small collection

Use Python 3 with SQLite FTS5. If python is unavailable, use python3 on macOS or Linux, or py -3 on Windows. Create a project directory containing index_notes.py and a folder named notes. Put the sample text file inside notes.

Download index_notes.py
View index_notes.py
import argparse
from contextlib import closing
from pathlib import Path
import re
import sqlite3
import textwrap


def passages(folder):
    if not folder.is_dir():
        raise ValueError(f"Notes folder not found: {folder}")
    for path in sorted(folder.rglob("*")):
        if path.is_symlink() or not path.is_file() or path.suffix.lower() not in {".md", ".txt"}:
            continue
        source = path.relative_to(folder).as_posix()
        blocks = re.split(r"\n[ \t]*\n", path.read_text(encoding="utf-8").strip())
        chunks = (chunk for block in blocks for chunk in textwrap.wrap(" ".join(block.split()), 600))
        for number, chunk in enumerate(chunks, start=1):
            yield source, number, chunk


def build_index(folder, database):
    rows = list(passages(folder))
    if not rows:
        raise ValueError("Add at least one nonempty .txt or .md note.")
    with closing(sqlite3.connect(database)) as connection, connection:
        connection.execute(
            "CREATE VIRTUAL TABLE IF NOT EXISTS notes "
            "USING fts5(source UNINDEXED, passage UNINDEXED, text)"
        )
        connection.execute("DELETE FROM notes")
        connection.executemany(
            "INSERT INTO notes(source, passage, text) VALUES (?, ?, ?)", rows
        )
    print(f"Indexed {len(rows)} passages in {database}.")


def find_notes(database, query):
    words = re.findall(r"[^\W_]+", query)
    if not words:
        raise ValueError("Use at least one word to search.")
    if not database.is_file():
        raise ValueError("Build the index before searching.")
    terms = " AND ".join(f'"{word}"' for word in words)
    with closing(sqlite3.connect(database.resolve().as_uri() + "?mode=ro", uri=True)) as connection:
        matches = connection.execute(
            "SELECT source, passage, snippet(notes, 2, '[', ']', ' … ', 24) "
            "FROM notes WHERE notes MATCH ? ORDER BY rank LIMIT 5", (terms,)
        ).fetchall()
    for source, passage, snippet in matches:
        print(f"{source}, passage {passage}\n{snippet}\n")
    if not matches:
        print("No matching passages.")


def main():
    parser = argparse.ArgumentParser(description="Build and search a local SQLite index.")
    parser.add_argument("--db", type=Path, default=Path("notes.sqlite3"))
    commands = parser.add_subparsers(dest="command", required=True)
    build = commands.add_parser("build")
    build.add_argument("folder", type=Path)
    find = commands.add_parser("find")
    find.add_argument("query")
    args = parser.parse_args()

    try:
        if args.command == "build":
            build_index(args.folder, args.db)
        else:
            find_notes(args.db, args.query)
    except (OSError, sqlite3.Error, ValueError) as error:
        raise SystemExit(str(error)) from None


if __name__ == "__main__":
    main()
Download library-notes.txt
View library-notes.txt
Workshop A begins at 14:30 in Room 204. Register at the front desk.

The media lab has a 3D printer. Reserve a slot at the media desk.

Standard book loans last 21 days. Renewals depend on holds.

The reading room closes at 6 p.m. on Saturdays.

Build and query the index

python index_notes.py build notes
python index_notes.py find "3D printer"
python index_notes.py find "Room 204"

The first command walks UTF-8 .txt and .md files, splits paragraphs into short passages, and stores them in notes.sqlite3. SQLite FTS5 keeps an index of the words in those passages. The other commands open the existing database, require every query word to match in a passage, and return ranked snippets with the relative filename and passage number.

You’re ready when

The sample file gives four indexed passages. “3D printer” should return passage 2, and “Room 204” should return passage 1. If SQLite reports “no such module: fts5,” use a Python distribution built with FTS5.

Edit a note and refresh

  1. Change Room 204 to Room 205 in the source file. Search for 205 before rebuilding, then rebuild and search again.
  2. Check that the rebuilt index finds 205 and has no match for 204. Rebuilding replaces the previous entries in one transaction.
  3. Try “borrowing longer.” This index searches word matches. To match paraphrases, add semantic retrieval.

The database holds a copy of the indexed passages. Keep it wherever you would keep the source notes and rebuild when you edit, remove, or add files.

Check your understanding

Why can a query return old text after a source file changes?

Queries read the saved database. Rebuild it to reflect edits in the source folder.

Sources

Search the guide