A library, and a librarian.
Until recently the librarian could only read the spines. Ask "where does it say
the budget is forty thousand?" and the best it could do was spot a file called
budget.pdf and offer to open it. If the sentence lived in
CR-réunion-mars.docx, it was never going to be found.
files_grep gives the librarian a card catalogue. One card per passage:
Three things about that card, and the third is the one that matters.
files_read already takes a byte offset.
The citation is verifiable by re-reading it. Nothing new was invented.Step 4 is the whole guarantee. The other 188 are not filtered out — they are never obtained. Three things follow from that, and none of them is available to a design that copies permissions into its index:
| Revocation is instant | There is no permission snapshot, so there is nothing to go stale. |
| The path is the asker's | A shared file is /Compta/budget.pdf for its owner and /Partagés/budget.pdf for you. Step 4 returns your path, so a hit feeds straight into files_read. A PROPFIND on the crawler's path would have 404'd. |
| One round trip, not 200 | oc:fileid is searchable with <d:eq>, so the whole candidate set is a single request. |
| A writes a document, greps a phrase in it | ✅ hit |
| B greps the same phrase | ❌ nothing |
| A shares to B | ✅ B finds it, at B's own path |
| A revokes | ❌ B finds nothing — immediately, with no re-index |
| A deletes | ❌ the row is gone |
That fourth row is what no ACL-mirroring index can give you. Nextcloud's own Context Chat documents the opposite: it "is not affected by the File Access Control rules and will respond with indexed information, even when the file is not accessible by the user."
This server has no Nextcloud account, and must never be pointed at an admin one. So a global crawl does not exist here. Two mechanisms, doing deliberately different jobs:
The webhook cannot read the file, and does not try. Its payload carries a fileid, a path and a uid — but no token, because it fires from a background job where no session exists. So it marks the row dirty and stops there. The next search by somebody who can actually open that file does the reading, with their own credential.
Which produces the right policy without anyone having to write it down:
A file nobody can open is never indexed, because there is nobody to open it.
| webhook | freshness. "Written three minutes ago, already searchable." Deletion is the exception and drops the row at once — that needs no credential to be correct. |
| lazy pass | completeness. One SEARCH for what changed since this user's mtime watermark, then a bounded drain of the dirty queue. |
Discovery is cheap and metadata-only, so the watermark races ahead of the reading. Extraction is capped at 20 documents per pass at concurrency 2, because the document server is a single instance and a large PDF is about five seconds. Nothing here ever blocks an answer: the pass is fire-and-forget.
A cold account is told so, in those words. "The index is still being built" is a different fact from "no match", and conflating them would be a confident lie.
Split on markdown headings; on labelled sections (Article 5, 3.2 Budget) in
prose that has none; on blank lines otherwise. No overlap — Chroma measured
the popular 800/400 default at 1.5 precision against 7.0 for the same size with
none, at equal recall.
Each passage carries the heading above it, and that heading is the whole
parent-child mechanism. The child is the indexed passage; the parent is
files_read(path, offset), which already existed. No parent table, no
deduplication, no 5–15× token blow-up at generation time. A citation names a
section, and nothing is stored twice.
The evidence for taking exactly this much and no more: HiChunk (ACL 2026) puts +6.97 evidence recall over fixed-200 chunks on hierarchical splitting plus the merge back to the parent — and only +3.6, flat on Fact-Cov, for the hierarchy without it. The merge is the load-bearing half, and it costs nothing here.
52 files: three PDFs including the 15 MB Nextcloud manual, plus ODT, ODP, ODS, DOCX and Markdown.
| Warm-up | 52/52 read in ~12 bounded passes, ~4 min of background work |
| Passages | 451, averaging 12.9 per document — 370 for the manual alone |
| Text indexed | 509 kB → a 3.1 MB .db, WAL included |
| Unreadable | 14 skipped (images, video), 3 parked for OCR |
Asked for "two-factor authentication", the manual answers:
That section title was recovered from a PDF with no structure of its own, by one regex. It is the good idea behind PageIndex — navigate by structure — obtained from an index instead of from LLM calls on every query.
The same manual yields two-factorauthentication and AdvancedTalk features.
FTS5 tokenises on whitespace, so a glued word is one token and searching half of
it misses. find.js solved this for a single file by folding whitespace away
entirely; an index cannot, because tokens are the index.
files_grep
finds the file and the section, then files_read({find})searches inside it space-blind. The tool description tells the model to try the word the document would actually use.
Extraction under 200 characters means a PDF with no text layer, and there is no
model in this process to look at it. The row is parked as needs_ocr and never
retried — so the day an OCR service exists, the backlog is one SELECT.
The same small local model that will do embeddings can do the OCR. One service, two jobs, arriving together.
Worth keeping, because each one is invisible from a unit test and each now has a regression guard.
A configuration fault ate the whole corpus. A missing ONLYOFFICE_JWT_SECRET
fails identically to an unreadable file, so every PDF in the drive got parked as
permanently unreadable — and fixing the config would never have brought them back.
A failure is no longer a verdict: the row stays dirty and counts, and only the
third attempt gives up.
The drain read the oldest files first. A document written a minute ago — the one someone is actually asking about — went to the back of a 500-file queue. Now newest-first, with failed rows behind everything untried, because otherwise a handful of broken files sit at the head and no pass ever reaches the rest.
The webhook's own rows sorted last. A row the webhook creates has no mtime
yet, and SQLite puts NULL last on ORDER BY … DESC. The single file we knew for
certain had just changed went to the very back of the queue — exactly inverting
the reason for having a webhook. COALESCE(mtime, 9e18): an unknown mtime means
"just now".
The short version, with the numbers. The long version, including what would change the answer, is in Decisions.
| Claim | Reality |
|---|---|
| "PageIndex hits 98.7% on FinanceBench" | Vendor-run, on Mafin 2.5 — a different product — against a benchmark whose own repo documents ground-truth errors. No independent replication. Costs LLM calls per query and $0.50–5.00 per 100-page PDF to index. |
| "Semantic chunking beats fixed" | Two independent nulls. ECIR 2025: 0.303 → 0.307 nDCG@5, for 2–4× the embedding time and non-deterministic output. |
| "Just filter the vector search by ACL" | pgvector filters after scanning, bounded by hnsw.ef_search (default 40). A user who can read 0.1% of a corpus gets ~0 rows for a LIMIT 10. |
| "Embeddings are just numbers" | Vec2Text recovers 92% of 32-token inputs exactly. A shared vector index is a shared document store. |
| "Nextcloud already has Context Chat" | Its own admin manual says it does not honour File Access Control, and multi-tenancy is unsupported. |
Meanwhile the strongest peer-reviewed number in the whole comparison favours the architecture that was already here: Amazon (AAAI 2026) measured agentic grep-and-reread at 94.5% of RAG's faithfulness, and beating it on FinanceBench (32.7% vs 24.2%). The missing piece was never a vector store — it was content-level candidate generation. That is what this page is.
FTS5 fails on exactly one thing: vocabulary mismatch. The question says "congés payés", the document says "RTT". So every call records what happened:
candidates = 0 → a hole in the index.candidates > 0, survivors = 0 → a permission boundary doing its job.When that last category is common enough to count, sqlite-vec goes on the
same rowids in the same file — a migration, not a rewrite. Its brute-force
scan is a feature here: exact, and it pages in rank order as cheaply as FTS5,
which the ef_search cliff above notably does not.
One thing that must not be bolted on casually: hybrid BM25+dense fusion without a reranker was measured as actively harmful (0.303 → 0.289, ECIR 2025), and three of four popular rerankers made MTEB-R worse than dense alone.
Backfilling the existing drive needs nothing: it happens by bounded passes on
every files_grep. Watch it warm up:
The .db holds the extracted text of every document a search has reached, which
makes it exactly as sensitive as the drive itself. It lives on a volume inside
mcp-nextcloud, on the credential side of the boundary, and
never in the orchestrator.