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.12 or 3.13. PyTorch stopped publishing x86_64 macOS wheels, so an Intel-built interpreter (common at /usr/local even on Apple Silicon) cannot install torch and cannot use the GPU. Check with python3 -c "import platform; print(platform.machine())" — it must say arm64.
  • 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]"

Copy the Messages database. Never point this tool at ~/Library/Messages: that file is live, Messages.app holds locks on it, and it is irreplaceable. Copying requires Full Disk Access for your terminal (System Settings → Privacy & Security → Full Disk Access).

mkdir -p ~/msgsearch
cp ~/Library/Messages/chat.db* ~/msgsearch/

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'

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.1.0.tar.gz (64.4 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.1.0-py3-none-any.whl (46.4 kB view details)

Uploaded Python 3

File details

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

File metadata

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

File hashes

Hashes for msgsearch-0.1.0.tar.gz
Algorithm Hash digest
SHA256 6ebb679e6793155eda22f1df012f44d66e958d5a36df88f79704a71664f12ea5
MD5 85cb7cd9951392fa9743c29b081f7001
BLAKE2b-256 50c2d586e0faae9a6a2b4916a763e6875978cb2429c25d995d71d97221f75f33

See more details on using hashes here.

Provenance

The following attestation bundles were made for msgsearch-0.1.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.1.0-py3-none-any.whl.

File metadata

  • Download URL: msgsearch-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 46.4 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.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 45970d7643fee8227ec569d27976a4a474f89c69f853e53da0019c3ef2d3431e
MD5 d0268f7f12e7ed417e125f5c3e64d46f
BLAKE2b-256 6f7f6056ae39c1676bec088daaee8126133ae751dd66651f4ac49b95d1041b34

See more details on using hashes here.

Provenance

The following attestation bundles were made for msgsearch-0.1.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

0.2.0

2 files

This release

0.1.0 This release

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