Skip to main content

msgsearch

Search your iMessage history by meaning, entirely on your own machine.

Apple's Messages search matches literal words. That fails whenever you remember what happened but not what was said — someone sent you a login months ago and the message containing it never uses the word "login", so no amount of typing finds it. This tool retrieves by meaning as well as by keyword, and it never sends your messages anywhere.

⚠️ Read this before you build an index

The index is a plaintext, searchable copy of every private thing anyone has ever texted you, including passwords, addresses and medical details. It is written to ~/msgsearch/index/ by default, outside this repository, and .gitignore is set up to make committing it difficult. Treat that directory the way you would treat a password manager's database. Both models run locally, so nothing is uploaded, but what lands on your disk is unencrypted.

Requirements

  • macOS with an arm64 Python 3.10 or newer. PyTorch stopped publishing x86_64 macOS wheels, so an Intel-built interpreter cannot install torch and cannot use the GPU — and Homebrew's /usr/local Python is an Intel build even on Apple silicon. Check with:

    python3 -c "import platform; print(platform.machine())"   # must say arm64
    

    If it says x86_64, point the installer at an arm64 interpreter explicitly:

    pipx install --python /Library/Frameworks/Python.framework/Versions/3.12/bin/python3 msgsearch
    

    Architecture is not something package metadata can express, so this fails at dependency resolution rather than with a helpful message. msgsearch doctor checks it.

  • About 3 GB of disk for dependencies and model weights.

Install

uvx msgsearch doctor          # run it without installing anything
pipx install msgsearch        # or install the command globally

Either pulls PyTorch, which is around 1 GB, so the first run is slow. Everything after that is local and fast.

To work on it instead, clone and install in place:

git clone https://github.com/dhruv1707/msgsearch && cd msgsearch
python3 -m venv .venv                  # must be an arm64 interpreter
./.venv/bin/python -m pip install -e ".[dev]"

Take a snapshot of the Messages database. Never point this tool at ~/Library/Messages: that file is live, Messages.app holds locks on it, and it is irreplaceable.

msgsearch sync

This needs Full Disk Access, which macOS grants to the application running the command (System Settings → Privacy & Security → Full Disk Access). A terminal usually has it; an editor's integrated terminal often does not.

It uses SQLite's backup API rather than cp, which matters more than it sounds. Messages runs in WAL mode, so your most recent messages live in a chat.db-wal sidecar until they are checkpointed — cp chat.db alone loses exactly the messages you are most likely to search for, silently. The snapshot folds the write-ahead log in and leaves a single self-contained file.

The default embedding model, google/embeddinggemma-300m, is gated. You must accept the Gemma licence at https://huggingface.co/google/embeddinggemma-300m and authenticate:

./.venv/bin/hf auth login

Any sentence-transformers model works instead if you would rather not, for example MSGSEARCH_EMBED_MODEL=BAAI/bge-small-en-v1.5.

Names

Without names, speakers appear as phone numbers. Resolving them makes results readable and improves retrieval, because the speaker label is part of the text that gets embedded — Sam: ... carries meaning where +15551234567: ... does not.

The simplest route is to grant Contacts access to whatever runs msgsearch (System Settings → Privacy & Security → Contacts — that is your terminal, or your editor if you run it from one). Names are then read automatically and stay current; nothing else is needed.

Contacts is a separate permission from Full Disk Access, and macOS grants it to the app, not the shell — so if you run msgsearch from an editor's integrated terminal, the grant has to go to the editor. To sidestep that entirely, copy the database from an app that does hold the permission and point at the copy, exactly as you did for chat.db:

cp ~/Library/Application\ Support/AddressBook/AddressBook-v22.abcddb ~/msgsearch/
export MSGSEARCH_ADDRESSBOOK=~/msgsearch/AddressBook-v22.abcddb

If you would rather not do either, or want to correct a name or label someone who is not in your address book, use the alias file instead:

./.venv/bin/msgsearch contacts                 # what is resolved, and from where
./.venv/bin/msgsearch contacts --template 20   # stub for the 20 busiest handles
$EDITOR ~/msgsearch/contacts.json              # fill in names; blanks are ignored
./.venv/bin/msgsearch contacts --import out.vcf  # or import a vCard export

The alias file overrides Contacts entry by entry, so a nickname you prefer wins. A handful of names goes a long way: on a typical archive the ten busiest handles account for over 90% of received messages.

Do this before building the index. Changing a speaker's name changes the text that was embedded, so it costs a full re-index afterwards.

Use

./.venv/bin/msgsearch doctor                  # is this machine set up correctly?
./.venv/bin/msgsearch explore                 # what is in your database
./.venv/bin/msgsearch index                   # build the index
./.venv/bin/msgsearch search "atria login"    # search it

If anything goes wrong, run msgsearch doctor first: it checks the interpreter architecture, PyTorch, the database, model access and the index, and prints the command that fixes whatever is broken.

Indexing everything takes a while — roughly half an hour per 100k messages on an M-series Mac — so restrict it to one conversation while trying things out:

./.venv/bin/msgsearch index --chat '+15551234567'

When new messages arrive, refresh and re-index:

msgsearch sync --index

Re-running msgsearch index later is cheap. Embeddings are cached by passage content, so an ordinary top-up only embeds text that is genuinely new — a rebuild with nothing new to do takes seconds rather than half an hour. Use --rebuild to force everything to be recomputed.

Useful search flags:

--limit N        how many results
--chat TEXT      restrict to conversations matching TEXT
--from WHO       restrict to a speaker
--after / --before YYYY-MM-DD
--type credential    only windows that appear to CONTAIN a credential
                     (credential_talk is the separate tag for windows that only
                     discuss one; also: email, phone, url, address)
--full           show the whole conversation window, not just the match
--rerank         run the cross-encoder (off by default; see below)
--no-dense       keyword search only
--no-bm25        vector search only

--type credential is the flag worth knowing about. Searching for a forgotten password without it returns mostly people discussing the password; with it, the message containing one tends to come first.

Reranking is off by default because it was measured and it hurts: over the gold set it drops MRR from 0.84 to 0.70, and on broad topical queries it takes rankings that fusion got right and scrambles them. --rerank turns it back on if you want to see for yourself.

How it works, and why

86% of your messages have no text. Apple sets message.text to NULL on most rows and stores the content in an attributedBody blob instead, as an archived NSAttributedString in the old typedstream format. Indexing the text column alone would cover about one message in seven, and the gap is worst on old messages and on messages you sent. attributed_body.py decodes the blob; it is verified to reproduce Apple's own text column exactly on all rows where both are present.

Timestamps are nanoseconds since 2001-01-01, not seconds since 1970.

handle_id is not the sender. It identifies the other party in the conversation, in both directions. Direction comes from is_from_me alone.

Retrieval separates the unit that is matched from the unit that is shown. Messages are grouped into windows: runs of conversation with no pause longer than thirty minutes. Windows are what you get shown, because a result without context is unreadable. But windows are the wrong thing to embed — compressing thirty messages on eight topics into one vector represents none of them. So embedding runs over passages: three-message slices that slide across each window. Measured on a real thread, the same target ranked 3539th when whole windows were embedded, 13th when three-message passages were, and 5117th when single messages were. Both too much context and too little are fatal.

Search combines two retrievers. BM25 finds literal words; vector search finds meaning; Reciprocal Rank Fusion merges the two ranked lists, whose scores sit on incomparable scales, by using positions rather than scores. A cross-encoder reranking stage exists but is disabled, because measuring it showed it made results worse.

Does it work?

eval/ holds a small set of labelled queries and the metrics for them, so claims here can be checked rather than believed. Current numbers, on a 9-query gold set over a single 105k-message conversation:

retriever MRR nDCG@10 recall@50
hybrid (default) 0.843 0.811 1.000
keyword only 0.667 0.706 1.000
vectors only 0.722 0.618 0.891
hybrid + reranking 0.700 0.677 1.000

Two things that table settles. Fusing the two retrievers genuinely beats either alone, which is the central design bet. And recall@50 of 1.000 means the right answer is always in the shortlist, so what remains is a ranking problem rather than a finding problem.

Nine queries is a small set, and it is stated here so you can weigh the numbers accordingly. See CONTRIBUTING.md if you want to change retrieval — measuring first is the one process rule this project insists on.

Layout

src/msgsearch/
  cli.py             the msgsearch command; all argument parsing lives here
  config.py          every tunable, with the measurement that justified it
  contacts.py        handle -> name resolution (alias file, vCard, AddressBook)
  doctor.py          setup preflight checks, each with the command that fixes it
  explore.py         inspect a chat.db and report what is in it
  attributed_body.py decode the attributedBody blob
  extract.py         database rows -> clean message records
  tagging.py         shape tags (credential, email, phone, url, address)
  chunk.py           messages -> conversation windows -> passages
  embedder.py        local embedding and reranking models
  index.py           build the index
  search.py          query it
tests/               synthetic fixtures only, never real message data
eval/                the verification loop: gold queries, metrics, labelling tool

The modules are importable as a library if you want the pieces without the CLI:

from msgsearch.extract import connect, iter_messages
from msgsearch.chunk import windows

with connect() as db:
    for window in windows(iter_messages(db)):
        ...

Contributing

See CONTRIBUTING.md. Two rules matter more than the rest: never point anything at ~/Library/Messages, and never commit real message data — not in tests, not in fixtures, not in issues.

./.venv/bin/python -m pip install -e ".[dev]"
./.venv/bin/ruff check . && ./.venv/bin/ruff format --check .
./.venv/bin/python -m unittest discover -s tests
./.venv/bin/python -m unittest discover -s tests

Licence

MIT.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

msgsearch-0.2.0.tar.gz (67.8 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

msgsearch-0.2.0-py3-none-any.whl (49.2 kB view details)

Uploaded Python 3

File details

Details for the file msgsearch-0.2.0.tar.gz.

File metadata

  • Download URL: msgsearch-0.2.0.tar.gz
  • Upload date:
  • Size: 67.8 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for msgsearch-0.2.0.tar.gz
Algorithm Hash digest
SHA256 00fc5671de3eb3620ad14192cd8247aea1e27fe956feea487a13309c06c50c43
MD5 b840ce8a400fc709e487458e1071f5ee
BLAKE2b-256 7f0c02aeb9fa53cc80b947c3ee20f15d342d1bf737d7e19aa80ce2cef7dd3c96

See more details on using hashes here.

Provenance

The following attestation bundles were made for msgsearch-0.2.0.tar.gz:

Publisher: release.yml on dhruv1707/msgsearch

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file msgsearch-0.2.0-py3-none-any.whl.

File metadata

  • Download URL: msgsearch-0.2.0-py3-none-any.whl
  • Upload date:
  • Size: 49.2 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for msgsearch-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 2ec5dbeb0697a393be3801b57bc13228786c29a16c385681d5e538feb7d9579a
MD5 85abb65369d3943f3cf26a5b5e23c430
BLAKE2b-256 0faf47c19c5d3ed3ad46b7917808a6038fd832ff319dafe3ec4b1918e20c8034

See more details on using hashes here.

Provenance

The following attestation bundles were made for msgsearch-0.2.0-py3-none-any.whl:

Publisher: release.yml on dhruv1707/msgsearch

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

0.2.10

2 files

0.2.9

2 files

0.2.8

2 files

0.2.7

2 files

0.2.6

2 files

0.2.5

2 files

0.2.4

2 files

0.2.3

2 files

0.2.2

2 files

0.2.1

2 files

This release

0.2.0 This release

2 files

0.1.0

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page