Skip to content

Allow this site to count your visits with Yandex Metrica? Metrica will store cookies in your browser to do this. How the site uses cookies

VC
Case 17 of 25 · AI · Knowledge base

An AI assistant for a company's knowledge base: a Telegram bot that cites its sources and knows who may see what

The bot answers questions from work chats, email, voice notes and documents, and attaches quotes from the sources to every answer. This approach is called retrieval-augmented generation, or RAG. I moved search out of memory into SQLite FTS5, the full-text search module built into the SQLite database. The bot now needs 20 times less memory, so it moved from a laptop to a server and keeps working at night when the laptop is off.

Industry
E-commerce, distributed team
Technology
Python · SQLite FTS5 · Telegram · MCP
Timeline
About 8 weeks of development
Outcome
Memory 1044 → 50 MB, search 253 → 13 ms
01 · Problem

Everything the company knew was buried in chats

The client is a US outdoor-gear brand with two Shopify stores and a distributed team: the owner, the ops team, an ad specialist and contractors. Decisions were not written down anywhere. Everything lived in work chats: tens of thousands of messages, voice notes where a document was needed, forwarded PDFs and screenshots. On top of that, there were more than 100,000 emails.

Remembering what had been decided two weeks earlier took half an hour: find the right chat, scroll through it, find the voice note and listen to all of it. Some decisions were simply lost, so the same question came up again and was decided all over again.

The owner put the condition plainly: "it matters that the knowledge base has as much data as possible." But the same chats held personal conversations and personal data the team must not see. So from the start the task was to search across all of the company's data without exposing anything that should stay private.

02 · Solution

The bot searches all work data, and access rights are set before anything is indexed

Data moves in one direction. It is collected, turned into text, cleaned of secrets and put into an index with access rights (an access list, or ACL). Only then does the language model write an answer. Access rights are applied on the way in, before indexing, so a cleverly worded question can't get around them.

01
Collection

Chats are downloaded in parts and merged by message number, plus the mail archive and forwarded documents

02
Conversion to text

Whisper transcribes voice notes and OCR reads screenshots; every piece of text keeps a link to its source

03
Removing secrets

Card numbers, CVV codes and passwords are removed before anything goes into the index

04
Index and access rights

SQLite FTS5 stores the index, my own code ranks the results, and access rights are set for each source

05
Answer

The language model writes an answer with required citations and sends it to Telegram

Collection keeps the chat history intact

Chats are downloaded in parts and merged by message number. The reason is simple: the source returns only the latest messages. If each download simply replaced the previous one, the first incomplete download would wipe the history. Whisper transcribes voice notes, OCR reads screenshots, and forwarded documents go into the index as separate sources. Everything ends up as text, and every fragment keeps a link to its source.

If a message is edited, the record is updated and the previous version goes into an edit history. Before, a repeated message number counted as a duplicate, and the edit was lost. Because of that, a team member's corrected reply stayed out of the base for 15 hours, and the old, truncated version led to the opposite conclusion.

Which materials reach the base

Images sent as files go to OCR. A PDF without a text layer is processed page by page: the pages are turned into images and also go to OCR. The limit is 20 pages, and the extracted text carries a note about it. Archives, including nested ones, are unpacked by the same module, up to 60 files and 40 MB. Slide decks can be read too. The audio from a video goes to the same speech recognizer as voice notes. A second, separate recognizer would mean two different ways of turning speech into text inside one system.

Figures from the first live run: 98 stuck attachments went back into the queue, 45 were processed right away, and 76 attachments outside work chats were deliberately left alone. One archive yielded 573,572 characters, and there were 14 such archives in the queue. Two PDFs of 9.7 and 16.3 MB returned exactly 0 characters until page-by-page OCR. The handler only knew about documents and photos, so 27 video messages slipped past it, 15 of them in the owner's private chat. From one such clip, a 2,201-character product breakdown has now been extracted.

The status says why a file wasn't processed

Three of the four lost attachments had the status "done": they looked processed and never showed up on any problem list. Now the status names the reason: no text, PDF without OCR, video, unsupported format, file too large. The summary lists everything that wasn't processed, and the run ends with a warning.

The result is now saved to the database after each attachment. Before, it was saved once, at the end of the cycle, and any failure reset all the statuses.

I rechecked the "empty" labels on the real files

None of the three labels held up: 12 "download failures" were hitting the size limit, 6 "videos with no sound" did have sound, and 20 "no text" labels hid a range of different causes. One more bug turned up: a check threw away all recognized text if it started with a bracket. OCR read a 771-character table without its first letter, the text started with a bracket, and the table landed in "no text".

Access levels are set in one place

There are three levels: owner, team and shared forum. Rights are set for each source. The important part comes next: two things read from the base, the bot and a module that builds a digest of facts. The digest filters data with its own separate code, and its rules could easily drift away from the bot's access rights.

So both parts call the same is_private_source function. A separate test watches over this: if someone adds a second copy of the privacy rules, the automated code checks (CI) fail.

The rule "don't index screenshots with passwords" rests on the same principle. It used to look at how the messenger saved the file; now it looks at how the text is extracted from it. The rule was changed in the same commit as the intake work. With two separate commits, there would have been a window when screenshots with keys could get into the index.

Search moved from memory to SQLite

The first version kept the whole index in the process's memory and ranked results with BM25, a classic formula that scores how well a document matches a query. That was enough while the bot lived on a laptop. I moved storage to SQLite FTS5 with one reservation: FTS5 only stores the index, the record of which words appear in which documents (via fts5vocab). My own code still decides the order of results.

The reason is concrete. The built-in bm25() function in FTS5 gives one number per document and hides how much each word contributed. That contribution shows whether a document matched all the words in the query or picked up its weight from one frequent word. So I rank the results myself.

Here is how the switch was checked. The search engine is changed with a single setting, the old and new engines ran the same queries, and the results were compared position by position. There were no differences. Only memory use and response time changed.

Answers come back complete

A model that reasons before answering sometimes returns an empty reply. When that happens, the bot retries once with a limit of 8,000 tokens. A measurement on a real request of 6,466 characters showed that the reasoning alone takes 400–700 tokens. Before this fix, the weakest answers came on the hardest questions, even though the main model itself was working fine.

What runs on top of the base

Other tools grew on top of the knowledge base:

  • Fact digest. Conversations are condensed into short, structured facts
  • Morning summaries in a shared Telegram forum: on the business, ad campaigns, servers and the tracker
  • About twenty watchdog scripts. If something breaks, they send a direct message and post to Discord. They keep an eye on the CRM, stuck orders, the deadline for answering chargebacks, important messages from the owner and how fresh the index is
  • An MCP server over the base (MCP is a standard way to connect data to AI assistants). The owner's AI assistants work with the same knowledge base, so a second system wasn't needed
03 · Technology

All of search runs on SQLite and my own ranking

Python

The core: collection, indexing, ranking and delivery, all in one language

SQLite FTS5 + fts5vocab

Stores the index; my own code ranks the results so it can see each word's contribution

Custom ranking (BM25 family)

Word weights plus how much of the query a document covers; the engine is switched by a single setting

LLM for answers (Claude / DeepSeek)

Answers are built only from the fragments found, and citations are required

whisper + OCR

Voice notes and screenshots become sources on a par with text

Telegram Bot API

A bot for direct messages plus a shared forum with topics for summaries and alerts

MCP server

The same knowledge base is available to the owner's AI assistants

systemd + Discord

Services, timers and about 20 watchdog scripts on the server that post alerts to a channel

pytest

76 test files, 1,027 tests, including the test that keeps the access rules from drifting apart

PythonRAGSQLite FTS5fts5vocabwhisperOCRTelegram Bot APIMCPsystemdpytest
Size of the code

164 Python modules, about 40,800 lines, 378 commits. The main parts: the index engine (~1,300 lines), the bot (~1,500), secret removal (~560), the fact digest (~430), Telegram delivery written on the standard library, the MCP server, the chat collector and about 20 watchdog modules.

Size of the index

15,864 fragments in the search index. The index moves to the server as a 32.7 MB file, and the server checks its checksum on arrival. The mail archive is two mailboxes: 94,800 and 8,022 messages.

04 · Results

The same search results with far less memory and time

Peak search memory
1044 MB 50 MB

20 times less, same results

Search response time
253 ms 13 ms

results compared position by position, no differences

Private fragments visible to the "team" level
1327 0

measured on the live index before and after the access-rights fix

All data collection moved from the laptop to the server

With 20 times less memory needed, the bot could move from the laptop to a server with only 961 MB of RAM. The old problem is gone: the laptop used to go to sleep, and the bot went silent at night. The bot now runs independently of the laptop.

The mail followed. The server handles IMAP downloads and parsing, and the laptop reads the prepared archive. It used to download 34 GB of raw mail to end up with 459 MB of parsed text. Since late July the server has been building the same archive itself: it takes 450 MB and on the whole is more complete. The laptop's disk was 98 percent full, with 12 GB free out of 460.

Simply switching the download off wasn't an option, because local tools read that archive. So files are fetched from an explicit list: mirroring the whole folder with deletion would have wiped files that other tools rely on. The switch is read both from an environment variable and from a marker file, because a variable would only last until the next reboot.

A separate check protects the history: a file is replaced only if the new one is no shorter than the local one. On the very first live run it blocked the swap for the second mailbox: 8,022 messages locally, 7,985 on the server. The messages the server was missing were not erased.

Along the way I removed a 103 MB index build from the laptop. It was rebuilt 96 times a day, and nothing read it: the last-read time of both its files matched the time they were written.

The watchdog scripts run on the server

About twenty watchdog scripts now run on the server on systemd timers. Their old background jobs on the laptop (launchd) are stopped and marked as disabled, and the rollback command is written down in the infrastructure log.

A telling case from the same round of work: the watchdog that checks the freshness of the messenger data reported "updated 0 minutes ago" for six hours while the collector was down. The script misread the file's modification time and got nonsense, and nonsense counted as zero age. Now the ways of reading it are tried in turn, and the script checks that it actually got a number.

Privacy, checked on the live index

You can discuss access rights in the abstract for a long time. So I measured on the live index how many private fragments the "team" level could actually see. The answer was unpleasant: 1327.

After the fix, this level sees 4560 fragments (it used to be 6139), and 0 of them are private. I also checked the access log separately: nobody had reached those fragments in the meantime.

What changed in daily work

"What did we decide about this?" is now a question for the bot. It answers with quotes from specific messages, including ones that started as voice notes. Morning summaries arrive on their own, and the watchdog scripts report failures on their own too. Through MCP, the owner's AI assistants work with the same base, so no second system with a copy of the data was needed.

05 · Where it fits

Where else this approach works

Behind this case is a common problem: a company's knowledge is scattered across conversations, and some of it must not be shown. It comes up in almost every team that has been working for more than a year:

  • → Support and sales: the history of customer conversations becomes a base of answers, and personal data stays closed
  • → Agencies and studios: calls, briefs and client voice notes can be found by search, and every answer comes with a quote
  • → Field services and construction: job requests, site photos, foremen's chats; one answer with a quote saves a round of phone calls
  • → Onboarding new hires: the "team" level gives them working context and keeps management's private conversations closed
  • → Companies that can't keep data in the cloud: everything except writing the answer runs on your own machine, and the index is a single SQLite file
What I reuse on later projects
  • Chat collection that merges by message number, so history survives an incomplete download from the source
  • Removing secrets before they reach the index, and one place for the privacy rules, with a test that keeps them from drifting apart
  • The "FTS5 stores the index, my code ranks the results" pattern: less memory, same results
  • Switching the search engine with one setting and comparing results position by position before and after
  • Watchdog scripts and morning summaries on top of the base, so the information you need arrives on its own
Have a similar task?

If your company's knowledge sits in chats and inboxes and opening it to everyone feels risky, this can be solved

I start with one source and one access level, so a working bot with citations appears well before the whole base is ready. Email, voice notes, documents and watchdog scripts come after that.