AI assistant over a company's internal data: a RAG bot in Telegram with access tiers
The bot answers questions across internal chats, email, voice notes and documents — always citing the source it took the answer from. It replies strictly from those documents (retrieval-augmented generation, RAG). Search was rewritten from in-memory BM25 to SQLite FTS5: memory dropped 20×, which let the bot move off a contractor's laptop onto a server, where it now answers around the clock.
Everything the business knew lived in chat threads
A US outdoor-gear brand, two Shopify stores, a distributed team: the business owner, ops people, an ad manager, contractors. No system of record for decisions. Everything lived in working chats: tens of thousands of messages, voice notes instead of documents, forwarded PDFs and screenshots, plus a mail corpus of more than 100,000 messages.
Answering a question like "what did we decide about this two weeks ago" cost half an hour of digging: scroll the thread, remember which thread it even was, find the voice note, listen to all of it. Some decisions were simply lost — they got re-asked and re-made.
The owner's requirement was blunt: "it matters that the knowledge base has as much data as possible." But those same chats held personal correspondence and PII that the team must not see. So the job read this way from day one: "give the company search across its own data without turning completeness into a leak."
A full RAG pipeline on the company's own data — privacy enforced before the index
The pipeline runs one way: raw material is collected, converted to text, stripped of secrets, indexed behind an ACL, and only then does the LLM synthesize an answer. Privacy is enforced on the way in, before anything is indexed — so it can't be talked around with a cleverly worded question.
Chats pulled per lane and merged by message id, plus the mail corpus and forwarded documents
whisper for voice notes, OCR for screenshots — everything becomes text with a link back to the source
Card numbers, CVVs and passwords are stripped before anything is written to the index
SQLite FTS5 as the postings store, a custom ranker, access tiers layered over sources
LLM synthesis with mandatory citations, delivered in Telegram
Collection: merge by id
Chats are pulled in lanes and merged by message id. The reason is plain: a naive copy-over would wipe history on the first partial pull — the source returns only the last N messages. Voice notes go through whisper, screenshots through OCR, forwarded documents are indexed as their own sources. Everything ends up as text, and every chunk keeps a link back.
Editing a sent message now updates the record, and the previous wording is filed into an edit history. Deduplication by id used to throw the edit away wholesale: one team member's reply sat in the chat for 15 hours, and the truncated version led to the opposite conclusion.
Attachment intake: what actually reaches the base
Images sent as documents go to OCR. A PDF with no text layer is rasterized and read page by page, capped at 20 pages, with an honest note left in the extracted text. Archives are unpacked recursively through the same extractor, bounded at 60 files and 40 MB. Slide decks are supported. A video's audio track goes to the same speech recognizer as voice notes: a second bespoke call would create a second truth about how we transcribe speech.
The first run in production: 98 stuck attachments returned to the queue, 45 extracted immediately, 76 attachments outside working chats deliberately left alone. One archive yielded 573,572 characters, and 14 such archives were sitting in the queue. Two PDFs of 9.7 and 16.3 MB returned exactly 0 characters until page-by-page OCR. The intake loop knew only documents and photos, so 27 video messages slipped past the handler entirely, 15 of them in the owner's private chat. A live clip yielded 2,201 characters of product analysis.
Honest statuses: you can see what failed to land
Three of the four lost attachments carried the status "done": they looked delivered and showed up on no problem list anywhere. The status now names the reason — no text, PDF without OCR, video, unsupported format, size limit exceeded. The summary prints everything that failed to land, and the run finishes on a warning.
The commit to the database happens after every attachment. A single commit used to sit at the end of the whole cycle, so any interruption wiped the statuses back to zero.
"Empty" labels re-checked against the live files
All three labels turned out to be wrong: 12 "download failures" were hitting the size limit, 6 "videos with no audio" had audio, and 20 "no text" cases were hiding different problems entirely. One finding stood out — a leading-bracket check was discarding recognized text in its entirety: a table came back with 771 characters and landed in "no text" because OCR had clipped its first letter.
Access tiers with a single source of truth
Three tiers: owner — team — hub. They are implemented as an ACL over sources themselves. And here's the part that actually matters: the base has two consumers — the bot and a fact-distillation job — and the job's filter is separate code that will happily drift away from the bot's ACL.
So both sides call the same
is_private_source
function, and a guard test watches for drift: if someone introduces a second copy of the privacy logic,
CI breaks.
The same principle holds up the "never index a screenshot with a password in it" guard. It was moved off how the messenger happened to file the attachment and onto the extraction type — and it was changed in the same commit as the intake work: two separate commits would have left a window in which screenshots containing keys were indexed.
Search: in-memory BM25 → SQLite FTS5
The first version kept the whole index in process memory. That was fine while the bot lived on a laptop.
I moved storage to SQLite FTS5 with one deliberate twist: FTS5
is used purely as a postings store (via
fts5vocab),
while ranking stays custom.
The reason is concrete: FTS5's built-in
bm25()
returns a single scalar per document and hides the per-term detail. That detail is what the coverage
factor runs on: how much of the query a document actually covers, kept separate from the weight it
picks up on one frequent word. Handing ranking to the engine would have meant worse results.
The migration was made risk-free: the engine is switched by a single environment variable, old and new were run against the same queries, and the output was compared rank for rank. No differences — only the cost changed.
Answers come back whole
An empty response from the reasoning model is cured by a single retry with a budget of 8,000 tokens. Measured on a production prompt of 6,466 characters, the reasoning itself takes 400–700 tokens — and the worst answers were coming back on exactly the hard questions, with the main model working perfectly.
What got built on top of the base
The knowledge base turned out to be a platform. Living on top of it:
- Fact distillation — correspondence is collapsed into structured facts
- Morning digests and boards in a Telegram hub: business pulse, campaign pulse, infra pulse, tracker digest
- About twenty watchdogs on a "if it breaks, DM me and post to Discord" principle: CRM liveness, stuck orders, the chargeback protection window, an important message from the owner, index staleness
- An MCP server over the base — the owner's personal models see the same knowledge base, with no second pipeline
The whole search runs on SQLite and a custom ranker
The core: collection, indexing, ranking, delivery — one language across the pipeline
Postings store; ranking stays custom for the sake of the coverage factor
Per-term weights plus query coverage; engine switched by a single ENV variable
Answers are assembled only from retrieved chunks, citations are mandatory
Voice notes and screenshots become first-class sources
A DM bot plus a forum hub with topics for digests and alerts
The same knowledge base is available to the owner's personal models
Services, timers, ~20 watchdogs on the server alerting into a channel
76 test files, 1,027 tests — including the guard test against ACL drift
164 Python modules, roughly 40,800 lines, 378 commits. The load-bearing pieces: the index engine (~1,300 lines), the bot (~1,500), the secret redactor (~560), distillation (~430), a stdlib-only Telegram delivery layer, the MCP server, the lane collector, and ~20 watchdog modules.
15,864 chunks in the search index. It ships to the server as a 32.7 MB file, and the receiving side verifies the checksum. The mail corpus is two mailboxes: 94,800 and 8,022 messages.
The same results at a different price
20× lower — with identical results
output compared rank for rank, no differences
measured on the live index, before and after the ACL fix
The whole collection pipeline moved off the laptop
The 20× memory drop paid for itself in practice. It's precisely what allowed moving the bot off a contractor's laptop onto a server with just 961 MB of RAM. The chronic "the laptop went to sleep, so the bot is silent at night" problem is gone at the root: it answers around the clock.
The mail followed. IMAP fetching of the mail corpus on the laptop is switched off, and the finished, parsed corpus is pulled from the server. The laptop was downloading 34 GB of raw mail to end up with 459 MB of parsed text; the server has been assembling the same corpus itself since late July, and its copy weighs 450 MB and is more complete. The laptop's disk was 98 percent full — 12 free gigabytes out of 460.
Switching the fetch off in one line was off the table: local tooling reads that corpus. So the pull works from an explicit file list — mirroring the whole directory with deletes would have taken other people's files with it — and the kill switch is read both from an environment variable and from a marker file, since a variable would only survive until the next reboot.
A separate guard protects the history: a file is replaced only when it is no shorter than the local one. On the very first production run the guard rejected the second mailbox (8,022 → 7,985) and prevented the silent erasure of 34 messages.
Along the way, a 103 MB index build was removed from the laptop: it was rebuilt 96 times a day and read by no consumer at all — the last-read time on both of its files matched the moment they were written.
The watchdogs live on the server
About twenty watchdog processes now run on the server under systemd timers. The launchd agents on the laptop are unloaded and renamed to a disabled form, and the rollback command is written down in the infrastructure journal.
A telling repair from the same pass: the messenger data-freshness watchdog spent six hours reporting "last updated 0 minutes ago" while the collector was dead. Parsing the output of the file-status utility was returning garbage, and garbage counted as zero age. The parse variants are now tried in order, and the result is checked for being a number in the first place.
Privacy: measured
You can argue about ACLs in the abstract for a long time. So I measured it on the live index: how many private chunks does the "team" tier actually reach? The answer was unpleasant — 1327.
After the ACL fix, the volume visible to that tier shrank from 6139 to 4560 chunks, of which private ones now number 0 — and no working data was lost. Separately, the access log was checked: it confirmed that no one had actually reached those chunks in the meantime.
What changed day to day
"What did we decide about this" is now a question to the bot, and it comes back with citations to specific messages — including ones that were originally voice notes. Morning digests arrive on their own, watchdogs speak up before a customer notices the problem, and the owner's personal models work against the same base through MCP — no second pipeline, no second copy of the data.
Where else the same methodology applies
Behind this case sits the standard problem of "company knowledge lives in unstructured conversation, and some of it must not be shown". Nearly every team older than a year has it:
- → Support and sales — customer conversation history as an answer base, without personal data spilling into general access
- → Agencies and studios — calls, briefs and client voice notes made searchable with a citation, instead of "I think we agreed on this"
- → Field services and construction — job requests, site photos, foreman chats; a cited answer replaces a round of phone calls
- → Onboarding new hires — the "team" tier gives working context without opening management's private correspondence
- → Companies restricted from the cloud — everything but synthesis runs on your own machine, out of a single SQLite file
- A collection pipeline that merges by id — history survives a partial pull from the source
- Secret redaction before the index, plus one source of truth for privacy with a guard test against drift
- The FTS5-as-storage plus custom-ranker pattern: cheap memory without giving up result quality
- Engine switching via ENV plus rank-for-rank output comparison — migration without taking anything on faith
- A watchdog and morning-digest layer over the base — knowledge starts arriving on its own
If your company's knowledge sits in chats and inboxes and opening it to everyone is scary — that's solvable
We start with one source and one access tier — a working bot with citations shows up long before the complete base does. Email, voice notes, documents and watchdogs get added after that.
Deep dive:Personal data and LLMs: 152-FZ, access levels and a 20-minute checklist
Related cases
Autonomous bots and backtesting: reliability engineering
Ten services deciding off an external event stream. Data arrives over WebSocket the moment an event fires…
Selective traffic routing through a self-hosted VPS
Static routes by AS ranges, targeted DNS bindings, configuration automated over the router's HTTP API, config…
Parser for GIS Treasury statements → 1C
XML protocol V3 + V4 → 1CClientBankExchange format, Windows-1251.
The 9,900 ₽ audit — with a concrete report and quote
I'll tell you what to deploy in your business first, what the payback looks like, and whether you need AI for the task at all (sometimes you don't).
Or just send your question — I reply within 2 hours