sift
You downloaded it. You can't find it. And when you do, it's forty pages.
sift searches your Downloads folder by meaning, not just by filename, and answers questions about what's in there. Everything runs on your own machine — no API keys, no accounts, no documents uploaded.
Installs as sift-downloads (plain sift was taken on PyPI). The command you
type is sift.
● sift /Users/you/Downloads
> rental agreement
1. ScannedRentalAgreement.pdf 0.98 name
2.4MB · 3mo ago
(no extractable text (scanned or image-only?))
2. lease-notes.md 0.64 ·
217B · 7mo ago
# Lease terms The notice period for terminating this lease is 60…
> ?what is the notice period and the deposit
Notice period: 60 days [lease-notes.md]
Security deposit: 100,000 INR, refundable within 30 days [lease-notes.md]
┌──────────────────────────| sift |──────────────────────────┐
│> │
└────────────────────────────────────────────────────────────┘
Type anything to search. Start with ? to ask a question instead. The prompt
stays at the bottom and results scroll up into your normal terminal history, so
nothing takes over your screen and you keep your scrollback.
Install
# 1. a local model runner (free, but ~5GB of models to download)
brew install ollama && brew services start ollama # macOS
# curl -fsSL https://ollama.com/install.sh | sh # Linux
ollama pull nomic-embed-text # 274MB — turns text into vectors
ollama pull llama3.1:8b # 4.9GB — writes the answers
# 2. sift (the command is `sift`; the package name has a suffix because
# plain `sift` was already taken on PyPI)
pip install "sift-downloads[watch]"
# 3. read your Downloads folder (a few hundred files takes under a minute)
sift index
# 4. go
sift
If anything looks wrong, run sift doctor. It checks each piece and prints the
exact command to fix whatever is broken.
On a small disk or a slow connection? The 4.9GB one is only used to write
the final answer. Swap it for something smaller — sift still finds files just
as well, and answers get a little blunter:
ollama pull llama3.2:3b
sift ask "..." --chat-model ollama_chat/llama3.2:3b # or SIFT_CHAT_MODEL
The 274MB embedding model is the one that does the searching, and it is not optional.
Using it
Run sift on its own for the interactive session above:
| Type this | What happens |
|---|---|
rental agreement |
searches for it |
?what is my notice period |
asks a question, answered from your files |
/open 2 /reveal 2 |
opens result 2, or shows it in your file manager |
/find -r invoice |
searches, preferring recently downloaded files |
/sync /status /help |
update the index, see what's indexed, list commands |
ctrl-d |
quit |
Or run single commands without the session:
| Command | What it does |
|---|---|
sift find "bank statement" |
ranked list of files; add --open 1 to open one |
sift ask "what's my policy number?" |
one grounded answer with sources |
sift index |
update the index (usually under a second) |
sift index --rebuild |
start over; needed after changing models |
(an upgrade that changes the index format re-embeds itself on the next sift index) |
|
sift unlock |
read your password-protected PDFs (asks for each password) |
sift status |
what's indexed, and what was skipped and why |
sift search "query" |
raw passage scores, for tuning |
sift watch |
keep the index updated as the folder changes |
sift purge |
delete the index (your files are untouched) |
find and ask update the index before running, so results are never stale.
Use --no-sync to skip that.
Why it finds files it can't read
A scanned PDF is a picture. There is no text inside to search. Same for a .zip
or a .dmg.
Those are exactly the files people lose, so sift keeps a record of every file
it sees and matches on filenames as well as contents. That is why
ScannedRentalAgreement.pdf is the top result above: sift cannot read a word of
it, and still finds it.
Each result shows how it matched — · means the contents matched, name means
the filename did. Identical copies like Statement (1).pdf collapse into one
result that tells you about its twins.
Password-protected PDFs
Banks send statements locked with a password. sift tells you which files those are, instead of guessing that they're scanned:
$ sift status
2 file(s) — password-protected (findable by name only):
· AccountStatement_40871876782.pdf
· lony3005_00000040871876782_E.pdf
→ sift unlock to read these
sift unlock asks for each password, reads the file, and adds it to the index.
The password is never stored — not in a file, not in your keychain — so
sift index --rebuild will ask again. That is deliberate. Two things worth
knowing before you run it:
- Unlocking puts that document's text into the index, which is not encrypted.
A file you locked on purpose becomes readable in
index.npz. - Some PDFs are locked only to stop printing or copying, and open with an empty password. sift tries that first, so those never reach you as a prompt.
Privacy
Your Downloads folder holds bank statements, ID scans and contracts. So:
-
No document text leaves your machine. Both models run locally through Ollama, so every byte of every file you index is read, embedded and answered on
localhost. -
Not even a phone-home. sift talks to models through litellm, which by default downloads a public price list of known models from
raw.githubusercontent.comwhen it loads. That request carries nothing about you, but it is still a request, so sift turns it off (LITELLM_LOCAL_MODEL_COST_MAP) and uses the copy shipped inside the package. sift does no cost accounting and never reads that list.The result is that a default run opens no connection except to Ollama on
localhost. Don't take our word for it —lsof -i, Little Snitch ortcpdumpwill tell you. Please check. -
The index holds the actual text of your documents. It lives in your system's user-data folder —
sift statusprints the path. Don't commit it or share the.npz.sift purgedeletes it. This includes anything yousift unlock— that text is stored in the clear like everything else. -
Cloud models need explicit permission. sift can use Anthropic, OpenAI or Gemini through litellm, but naming a cloud model is not enough. It refuses without
--allow-cloud, and once you have allowed it, every cloud model names itself before it is used. Sending your documents to someone else's server should be a decision, not a side effect of editing a config value.
export ANTHROPIC_API_KEY=sk-...
sift ask "..." --chat-model anthropic/claude-sonnet-4-5 --allow-cloud
The two models are separate settings. You can keep embeddings local, so your whole folder stays home, and use a cloud model only to write the final answer from the few passages retrieved.
Permission is asked per command, for the models that command actually calls.
sift index and sift find only ever use the embedding model, so a local
embedder plus a cloud chat model indexes and searches with no --allow-cloud
at all; sift ask is where the question is put to the cloud model, so that is
where it asks. The warning follows the same rule and names each cloud model
once, so a run that embeds with one provider and answers with another tells you
about both.
Running a model server yourself? ollama/ and lm_studio/ always count as
local — they only ever reach a server you chose. huggingface/ is different:
litellm sends it to router.huggingface.co unless you point it somewhere, so
sift treats it as a cloud model until you set HF_API_BASE (or
HUGGINGFACE_API_BASE) to your own endpoint.
Settings
Nothing needs configuring. Everything can be. A CLI flag beats an environment
variable, which beats .env, which beats the default.
| Setting | Flag | Env var | Default |
|---|---|---|---|
| Folder to search | --source |
SIFT_SOURCE |
your Downloads folder |
| Where the index lives | --data-dir |
SIFT_DATA_DIR |
system user-data folder |
| Embedding model | --embed-model |
SIFT_EMBED_MODEL |
ollama/nomic-embed-text |
| Answering model | --chat-model |
SIFT_CHAT_MODEL |
ollama_chat/llama3.1:8b |
| Chunk size / overlap | --chunk-size / --chunk-overlap |
SIFT_CHUNK_SIZE / SIFT_CHUNK_OVERLAP |
1000 / 150 |
| Indexed unit, max / min | — | SIFT_CHILD_SIZE / SIFT_CHILD_MIN |
300 / 200 |
| Document opening kept per passage | — | SIFT_DOC_HEAD_CHARS |
120 |
| Passages per answer | --top-k |
SIFT_TOP_K |
5 |
Relevance bar for ask |
--min-score |
SIFT_MIN_SCORE |
0.55 |
Candidate bar for find |
— | SIFT_FIND_MIN_SCORE |
0.40 |
| Largest file to read | --max-file-mb |
SIFT_MAX_FILE_MB |
50 |
| Allow cloud models | --allow-cloud |
SIFT_ALLOW_CLOUD |
off |
sift is not limited to Downloads — sift find "..." --source ~/Documents works
fine. Downloads is just where this problem actually bites.
See .env.example.
A warning about --min-score. The default 0.55 was measured for
nomic-embed-text on one particular set of documents. It does not transfer. If
you change embedding models, work out your own — see
the calibration guide.
Keeping the index fresh
The index updates incrementally. sift records each file's size and modification
time, so a sync only re-reads what actually changed — about a second, against
forty for a full rebuild. That is why find and ask can afford to sync before
every query.
sift watch keeps it updated while you work: it re-syncs a few seconds after
the folder goes quiet, and it runs one sync at a time. Ctrl-C lets a sync that
is already running finish — up to 30 seconds — rather than killing it partway
and throwing the work away. Press Ctrl-C again to drop it immediately; the next
sync picks up where it left off either way.
If you want it updated without a terminal open, contrib/ has
ready-made launchd and systemd files. They are documented, not installed for
you. sift never writes to your system.
What it can't do
- No OCR. Scanned PDFs give up no text, so their contents can't be searched.
They stay findable by filename. (A locked PDF is a different problem with a
real fix — see
sift unlockabove.) - No re-ranker. Ranking is by topic similarity, not by "does this answer the question", so a document merely about your query can outrank the one that answers it. A cross-encoder was built and measured against the worst case in this folder; it did not reliably fix it, and the fix turned out to belong at ingestion instead. It isn't shipped.
- Top level only. sift doesn't walk into subfolders, on purpose — one unzipped project would drag in thousands of files.
- Answers aren't guaranteed correct. A small local model can still drift past its instructions. The one hard rule is that if nothing relevant is found, sift refuses without calling the model at all.
- Grounded is not the same as correct. The failure worth knowing about isn't invention — it's attribution. If your documents contain two people's job titles, or two people's account numbers, a model can hand you one person's real, correctly cited value as the other's. Nothing is fabricated, so a "check it against the sources" pass sees nothing wrong. Chunking is set up to make this less likely (above), not to make it impossible.
The design notes go into why, and what would fix each one.
How it works
A RAG pipeline built from scratch — no LangChain, no vector database. Text is split into overlapping chunks, each chunk becomes a vector, and search is one dot product against a matrix of unit vectors.
The text sift matches is not the text it reads to you. One window can't do both jobs: matching wants it small, so the embedding is about one thing; answering wants it large, so the model can see enough to be right. So each passage is indexed as a small unit (≈300 characters, cut on line boundaries) and served as the ~1000-character window around it. A question matches something precise; the model reads the whole passage it came from.
Two limits on that, both of which exist because of measured failures rather than taste:
- An indexed unit has a floor (
SIFT_CHILD_MIN, 200). Cut smaller and a passage stops being about anything: a payslip row reading onlyBank A/C No 12345…embeds as an account number, so a question about somebody else's bank account retrieves your payslip. At 200 the same text embeds as a payslip, and stops matching. - Each passage carries its document's opening line (
SIFT_DOC_HEAD_CHARS, 120). Documents name their owner once, at the top, and never again — so a clause lifted from page 4 of a form cannot be attributed to anyone. Given a tax form containing both the employee's job title and the HR signatory's, every model tested answered "what is my designation?" with the signatory's title. With the opening line attached, they answer correctly.
Both numbers were measured on one folder of real documents. Like --min-score,
they are starting points, not constants.
→ Design notes — the pipeline, which file to read first, why the vector store is shaped the way it is, how to calibrate the relevance bar, and the full limitations.
Development
pip install -e ".[watch,dev]"
pytest # unit suite: 560 tests, ~3s. No Ollama, touches no real folder
ruff check . # both of the above are required CI checks
pytest evals/ # answer quality: 17 tests, ~60s. Needs Ollama, never runs in CI
The unit suite proves the code does what it says. It deliberately cannot reach
a model — real embedding calls raise — so it can't tell you whether the answers
are any good. evals/ is that second suite: a small synthetic corpus
and a fixed set of questions, scored against real models. Of the two
wrong-entity failures 0.2.0 fixed, one is genuinely pinned there — revert the
fix and the test fails. The other does not reproduce on invented documents, so
its test is labelled a guard rather than a proof.
evals/README.md says which is which, and why the
difference matters. It isn't run in CI, because it needs a model server.
Releases go out by pushing a vX.Y.Z tag — the workflow checks the tag against
the packaged version, the changelog and main before it publishes anything, and
uploads to PyPI without an API token existing anywhere. See
docs/RELEASING.md.
License
MIT — see LICENSE.
Metadata
Release files for sift-downloads 0.3.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| sift_downloads-0.3.0.tar.gz | 167.3 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| sift_downloads-0.3.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 239.1 kB
Release files / sift_downloads-0.3.0.tar.gz
| Download URL | sift_downloads-0.3.0.tar.gz |
|---|---|
| Size | 167.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
a5296176d075e09b6cd9cfa214a9eeee5bcd0fbaae982bf3f2573abb19f6fe88
|
|
BLAKE2b-256 checksum How to use checksums |
0ae7bfe400390072cd7568c708c855642310dfeae94e6ee1863fcdc5242aeef8
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 19, 2026.
Transparency logRelease files / sift_downloads-0.3.0-py3-none-any.whl
| Download URL | sift_downloads-0.3.0-py3-none-any.whl |
|---|---|
| Size | 71.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
11bf8d3a69f78ab4822455a9bd4843090ee511ca10ccdbde43e44b12d28f9e7d
|
|
BLAKE2b-256 checksum How to use checksums |
d35a6580b68f6fad24e1592647c68d5cc0bc6b80894e72a9c7f54df8db6310b2
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 19, 2026.
Transparency log