Case study
Разбор проекта
Enclave
A private AI knowledge assistant for small businesses that turns scattered PDFs, SOPs, and manuals into searchable answers, entirely on hardware the business already owns.
Локальный ИИ-ассистент для малого бизнеса, превращающий разрозненные PDF, регламенты и инструкции в доступные для поиска ответы, целиком на оборудовании, которое у бизнеса уже есть.
Tauri 2RustReactPostgreSQL 18pgvectorRow-Level Securityllama.cppQwen3LoRA
01 · Context
01 · Контекст
Large companies have information overload, and people whose job is managing it
Most of the AI-for-business conversation is aimed at enterprise: Microsoft, Google, Atlassian, Notion, and a hundred well-funded startups building "corporate RAG" for organizations that already have IT departments, procurement processes, and six-month deployment cycles. A small business, a 15-person manufacturing shop, a 20-person law office, a logistics company, an accounting firm, a clinic, has the same underlying problem, in some ways a worse version of it: years of manuals, SOPs, invoices, case files, and emails scattered across folders and inboxes, with no one whose actual job is organizing it. Often the owner is the knowledge base, the person everyone asks, and the business visibly slows down whenever they're unavailable.
That's a sharper problem to design for than "enterprise knowledge management," and a different one: cost sensitivity is real, not a line item; there's no dedicated administrator to run a complex deployment; and sending client files, patient records, or unreleased financials to a third-party cloud AI service is a real objection, not a hypothetical compliance concern. Enclave is built for that business: a private, on-prem knowledge assistant that turns the documents already sitting in its folders into searchable answers, without a cloud dependency and without needing an IT team to run it.
Even a business this size has real internal boundaries worth respecting in software, not just in policy: an accounting firm's client-engagement teams shouldn't casually surface each other's files in a shared search; a law office's active cases carry real confidentiality expectations between the people working them. Enclave treats that boundary as a database-level guarantee rather than an application convention: one deployment, one shared model, and a security boundary that lives where a boundary belongs, not in whichever code path happened to remember to filter correctly that day.
It's fully on-prem by requirement, not preference, for the same reason the market segment demands it: the desktop shell, the database, and the language model inference all run on hardware the business already owns: no document, no query, and no answer ever leaves the network. That constraint rules out the easy version of almost every feature: retrieval, generation, and even per-team model behavior all have to be solved locally, on modest hardware, without a cloud fallback to lean on.
The database and the inference engine are both self-bundled, for the same reason: a fifteen-person shop has no one to run a container orchestrator, let alone troubleshoot one. Enclave ships its own PostgreSQL instance, initialized on first run, and downloads a matching local inference engine, chosen automatically for the machine it lands on: CUDA where an NVIDIA GPU supports it, Vulkan as the portable fallback, CPU-only otherwise. Twenty-five architecture decision records document the reasoning behind these choices as the system evolved past its first design; several reverse or replace earlier ones outright, and this write-up reflects the current state, not the first one.
Крупные компании могут позволить себе информационный хаос. Малый бизнес не может.
Большая часть разговоров об искусственном интеллекте для бизнеса сосредоточена на корпоративных решениях. Microsoft, Google, Atlassian, Notion и сотни хорошо финансируемых стартапов создают корпоративные RAG системы для организаций, у которых уже есть IT отдел, выстроенные процессы закупок и многомесячные циклы внедрения. Однако у малого бизнеса, будь то производство с пятнадцатью сотрудниками, юридическое бюро, логистическая компания, бухгалтерская фирма или частная клиника, существует та же фундаментальная проблема, а зачастую она проявляется ещё острее. За годы работы накапливаются инструкции, регламенты, счета, документы клиентов и переписка, которые оказывааются разбросаны по папкам и почтовым ящикам. При этом в компании обычно нет человека, чья работа заключается в том, чтобы поддерживать всю эту информацию в порядке. Во многих небольших компаниях роль базы знаний фактически выполняет сам владелец. Именно к нему обращаются с вопросами сотрудники, и стоит ему оказаться недоступным, как работа бизнеса начинает заметно замедляться.
Эта задача принципиально отличается от управления знаниями в крупных организациях. Для малого бизнеса стоимость внедрения имеет значение, а не является очередной строкой бюджета. Здесь нет выделенного администратора, способного сопровождать сложную систему, а передача договоров клиентов, медицинских данных или внутренней финансовой отчётности в облачный сервис искусственного интеллекта часто оказывается неприемлемой. Enclave создавался именно для таких компаний. Это локальный ассистент знаний, который превращает уже существующие документы в доступную для поиска базу знаний, не требуя облачной инфраструктуры, постоянного подключения к интернету или собственной IT команды.
Даже в небольших компаниях существуют реальные границы доступа, которые должны обеспечиваться архитектурой системы, а не внутренними регламентами. Сотрудники бухгалтерской фирмы, работающие с разными клиентами, не должны случайно получать доступ к чужим документам. Юристы, ведущие разные дела, ожидают, что материалы останутся доступны только тем, кто непосредственно участвует в работе. Enclave реализует такую изоляцию на уровне базы данных. Все команды используют одно развёртывание и одну языковую модель, однако доступ к данным определяется самой архитектурой системы, а не зависит от того, была ли очередная проверка прав доступа реализована без ошибок.
Полностью локальная архитектура является не особенностью продукта, а его фундаментальным требованием. Десктопное приложение, база данных, векторный индекс и инференс языковой модели работают на оборудовании, которое уже находится в компании. Документы, пользовательские запросы и ответы модели никогда не покидают внутреннюю сеть. Это ограничение определяет каждое архитектурное решение в проекте. Индексация документов, поиск, генерация ответов и разграничение доступа должны эффективно работать без облачных сервисов, на обычном офисном оборудовании и без внешней вычислительной инфраструктуры.
База данных и движок инференса обе поставляются в комплекте с приложением, по одной и той же причине: в компании из пятнадцати человек некому запускать оркестратор контейнеров, не говоря уже о том, чтобы его чинить. Enclave разворачивает собственный экземпляр PostgreSQL при первом запуске и скачивает подходящий локальный движок инференса, выбранный автоматически под конкретную машину: CUDA, если видеокарта NVIDIA её поддерживает, Vulkan как переносимый запасной вариант, только CPU в остальных случаях. Двадцать пять архитектурных решений (ADR) документируют рассуждения за этими выборами по мере того, как система прошла путь дальше первой версии дизайна; часть из них прямо отменяет или заменяет более ранние, и этот разбор отражает текущее состояние, а не первое.
02 · Requirements & constraints
02 · Требования и ограничения
What it must do, and under what conditions
| FR | Ingest a business's existing documents, including PDFs, DOCX files, and spreadsheets, into a department's knowledge base. Answer questions via hybrid retrieval, combining dense vector and lexical keyword search over that department's documents only. Chat with a locally-run LLM whose tone and vocabulary are specialized per department via a LoRA adapter. Run numeric calculations over ingested spreadsheet data, sums, counts, averages, grouped breakdowns, without ever letting a model write SQL directly. Support multiple concurrent ingestion jobs without duplicate processing. |
|---|---|
| NFR | Department isolation must hold at the database layer: application code must be structurally incapable of returning another department's rows, not merely unlikely to. All inference and storage on-prem, with first-run downloads restricted to a small set of pinned, hash-verified official sources for the models and inference engine. The database role serving ordinary application queries must hold the minimum write privileges the app actually needs, not whatever Postgres grants by default. Installation must not require Docker, a system service, or administrator rights, since the target business has no IT staff to run any of those. |
| Constraints | PostgreSQL 18 with pgvector 0.8.3, both self-bundled and self-managed by the app rather than installed separately. Desktop-shell deployment via Tauri 2, not a hosted service. llama.cpp's llama-server run as an owned sidecar process for both chat and embeddings, not Ollama. The security model has to hold without a network perimeter to lean on. |
Что система обязана делать, и в каких условиях
| FR | Загрузить существующие документы бизнеса, включая PDF, файлы DOCX и электронные таблицы, в базу знаний отдела. Отвечать на вопросы через гибридный поиск, комбинируя плотный векторный и лексический ключевой поиск только по документам этого отдела. Общаться с локально запущенной LLM, чей тон и лексика специализированы под отдел через LoRA-адаптер. Выполнять числовые расчёты по загруженным данным электронных таблиц, суммы, счётчики, средние, разбивки по группам, не позволяя модели напрямую писать SQL. Поддерживать несколько одновременных задач загрузки без дублирующей обработки. |
|---|---|
| NFR | Изоляция отделов должна держаться на уровне базы данных: код приложения должен быть структурно неспособен вернуть строки другого отдела, а не просто маловероятно это сделать. Весь инференс и хранение работают on-prem, а загрузки при первом запуске ограничены небольшим набором закреплённых, проверяемых по хэшу официальных источников моделей и движка инференса. Роль базы данных, обслуживающая обычные запросы приложения, должна иметь минимально необходимые права на запись, а не то, что Postgres выдаёт по умолчанию. Установка не должна требовать Docker, системной службы или прав администратора, поскольку у целевого бизнеса нет IT-персонала для их настройки. |
| Ограничения | PostgreSQL 18 с pgvector 0.8.3, оба поставляются в комплекте и управляются приложением самостоятельно, а не устанавливаются отдельно. Развёртывание в виде десктоп-оболочки через Tauri 2, не хостинговый сервис. llama-server из llama.cpp работает как собственный сайдкар-процесс для чата и для эмбеддингов, не Ollama. Модель безопасности должна держаться без сетевого периметра, на который можно опереться. |
03 · Architecture
03 · Архитектура
The department boundary is set once, per request, in Rust
A React UI runs inside the Tauri webview; a Rust core handles every Tauri command and is the only thing that ever opens a database connection. Before issuing any query, the Rust core sets the active department as a session variable: the single point where who is asking becomes a fact the database itself can enforce. From there, three local services do the work: a self-bundled PostgreSQL instance with pgvector for hybrid retrieval, plus two separate llama-server processes, one for chat and one for embeddings, each an owned sidecar rather than a shared daemon like Ollama. Nothing in this diagram reaches outside the machine, except for the one-time, hash-verified download of the models and the inference engine binaries on first run.
Граница отдела выставляется один раз, на запрос, в Rust
React-UI работает внутри webview Tauri; Rust-ядро обрабатывает каждую Tauri-команду и является единственным, что вообще открывает соединение с базой данных. Перед любым запросом Rust-ядро выставляет активный отдел как переменную сессии: единственную точку, где то, кто спрашивает, становится фактом, который сама база способна обеспечить. Дальше работают три локальных сервиса: собственный встроенный экземпляр PostgreSQL с pgvector для гибридного поиска, а также два отдельных процесса llama-server, один для чата и один для эмбеддингов, каждый как собственный сайдкар, а не общий демон вроде Ollama. Ничто на этой диаграмме не выходит за пределы машины, кроме разовой, проверяемой по хэшу загрузки моделей и бинарников движка инференса при первом запуске.
04 · Key decisions
04 · Ключевые решения
Six decisions that shaped the system
Digests of the ADRs that mattered most, out of roughly twenty-five written across this project's life so far. Each one names what the decision cost: a trade-off you don't acknowledge is a trade-off you didn't see.
Шесть решений, определивших систему
Выжимки из наиболее значимых ADR, из примерно двадцати пяти, написанных за жизнь проекта на данный момент. В каждой названа цена решения: трейд-офф, который не назван, это трейд-офф, который не был замечен.
llama.cpp as an owned sidecar, not Ollama
llama.cpp как собственный сайдкар, не Ollama
- Options
- Ollama as the local model runtime · a pure-Rust inference engine (candle, mistral.rs) · llama.cpp's llama-server run directly as an owned sidecar process.
- Chosen
- llama-server, spawned and owned by the Tauri core as a child process, one instance for chat and one for embeddings.
- Why
- The system needs per-request LoRA adapter selection, the request names which department's adapter to apply, and at the time this was decided, llama.cpp's
lora: [{id, scale}]request parameter supported that directly, while Ollama's own adapter hot-swap support lagged behind it. Adapters preload once at startup via--lora-init-without-applyand get applied per request rather than reloaded. The pure-Rust engines were rejected as less mature for LoRA specifically at decision time, not on performance grounds. - What it cost
- Owning the sidecar means owning its lifecycle: spawn, health-check, restart on crash, and clean shutdown are all now Enclave's problem rather than a background service's. It also means shipping and hardware-matching the inference engine binary itself, CUDA, Vulkan, or CPU build, rather than delegating that to a separately-installed tool.
- Варианты
- Ollama как локальный рантайм модели · чисто-Rust движок инференса (candle, mistral.rs) · llama-server из llama.cpp, запущенный напрямую как собственный сайдкар-процесс.
- Выбрано
- llama-server, порождаемый и принадлежащий Tauri-ядру как дочерний процесс, один экземпляр для чата и один для эмбеддингов.
- Почему
- Системе нужен выбор LoRA-адаптера на уровне запроса, запрос называет, адаптер какого отдела применить, и на момент принятия решения параметр запроса llama.cpp
lora: [{id, scale}]поддерживал это напрямую, тогда как собственная поддержка горячей замены адаптеров у Ollama отставала. Адаптеры предзагружаются один раз при старте через--lora-init-without-applyи применяются на уровне запроса, а не перезагружаются заново. Чисто-Rust движки были отклонены как менее зрелые именно для LoRA на момент решения, не по причинам производительности. - Цена
- Владение сайдкаром означает владение его жизненным циклом: запуск, проверка состояния, перезапуск при падении и корректное завершение теперь забота Enclave, а не фоновой службы. Это также значит поставку и подбор под оборудование самого бинарника движка инференса, сборка CUDA, Vulkan или CPU, вместо того чтобы делегировать это отдельно устанавливаемому инструменту.
RLS checked once per query, not once per row
RLS проверяется один раз на запрос, не на строку
- Options
- Leave the membership-check predicate as a plain subquery in the RLS policy · rewrite the predicate so Postgres evaluates it once per query instead of once per row · narrow which tables carry a policy at all to cut RLS's cost.
- Chosen
- Rewrite the policy predicate as
department_id = ANY ((SELECT current_user_department_ids())::uuid[]). Wrapping the membership check in(SELECT ...)makes Postgres treat it as a one-time InitPlan rather than a function call repeated for every row scanned. - Why
- A latency investigation on a 50,000-row spreadsheet-ingestion benchmark found search dropping from 2.2 seconds to 220 milliseconds once RLS's real cost was isolated: 257 milliseconds with the original policies active, 20 milliseconds with them stripped entirely, and 0.08 milliseconds on a bare index with no RLS at all. Two things were adding that overhead: the membership subquery running per row, and a set of dormant policies left over from an earlier schema that Postgres was still evaluating and ORing together with the active ones on every scan.
- What it cost
- The InitPlan rewrite only pays off because the same migration also had to find and remove the dormant policies, by the functions they call rather than by name, since names differ between databases that predate the migration system and ones that don't. Verifying the removal was safe required a before-and-after visibility fingerprint, row counts and id hashes across eight tables for four test users, rather than trusting the migration script alone.
- Варианты
- Оставить проверку членства как обычный подзапрос в политике RLS · переписать предикат так, чтобы Postgres вычислял его один раз на запрос, а не один раз на строку · сузить набор таблиц, несущих политику вообще, ради снижения цены RLS.
- Выбрано
- Переписать предикат политики как
department_id = ANY ((SELECT current_user_department_ids())::uuid[]). Обёртывание проверки членства в(SELECT ...)заставляет Postgres воспринимать её как разовый InitPlan, а не как вызов функции, повторяемый на каждую просканированную строку. - Почему
- Расследование задержки на бенчмарке загрузки таблицы из 50 000 строк показало падение времени поиска с 2,2 секунды до 220 миллисекунд, как только реальная цена RLS была выделена отдельно: 257 миллисекунд с исходными активными политиками, 20 миллисекунд при их полном удалении и 0,08 миллисекунды на голом индексе вовсе без RLS. Эти накладные расходы складывались из двух вещей: подзапрос членства выполнялся на каждую строку, и набор спящих политик, оставшихся от более ранней схемы, который Postgres всё ещё вычислял и объединял через OR с активными политиками при каждом сканировании.
- Цена
- Переписывание в InitPlan окупается только потому, что та же миграция должна была найти и удалить спящие политики, по функциям, которые они вызывают, а не по имени, поскольку имена различаются между базами, предшествующими системе миграций, и теми, что нет. Проверка безопасности удаления потребовала снятия отпечатка видимости до и после, счётчики строк и хэши идентификаторов по восьми таблицам для четырёх тестовых пользователей, а не доверия одному лишь скрипту миграции.
department_id denormalized onto chunks and chunk_embeddings
department_id продублирован в chunks и chunk_embeddings
- Options
- Keep
department_idonly ondocumentsand have child tables inherit scope through a join · duplicatedepartment_iddirectly ontochunksandchunk_embeddings, an explicit, documented departure from third normal form. - Chosen
- Duplicate it. Both
chunksandchunk_embeddingscarry their owndepartment_idcolumn, kept in sync with the parentdocumentsrow at write time. - Why
- Both the RLS predicate and the vector index's approximate-nearest-neighbor prefilter need department on the exact row being examined, on the hottest path in the system, a retrieval query touching potentially tens of thousands of chunk rows. Joining out to
documentson every one of those rows to discover its department was rejected specifically because it puts a join in the loop that both the security boundary and the index scan depend on. - What it cost
- This is a real, acknowledged violation of normal form, and it creates exactly the kind of second place a value can drift out of sync that the original data model tried to avoid. The write path has to be trusted to keep the copy consistent; nothing in the schema enforces it structurally beyond careful transaction discipline at ingestion and deletion time.
- Варианты
- Держать
department_idтолько вdocuments, а дочерним таблицам наследовать область через join · продублироватьdepartment_idнапрямую вchunksиchunk_embeddings, явное, задокументированное отступление от третьей нормальной формы. - Выбрано
- Продублировать. И
chunks, иchunk_embeddingsнесут собственную колонкуdepartment_id, синхронизируемую с родительской строкойdocumentsпри записи. - Почему
- И предикату RLS, и предфильтру приближённого поиска ближайших соседей векторного индекса нужен отдел прямо на той строке, что рассматривается, на самом горячем пути системы, запросе поиска, затрагивающем потенциально десятки тысяч строк чанков. Join к
documentsна каждой из этих строк ради выяснения её отдела был отклонён именно потому, что вставляет join в цикл, от которого зависят и граница безопасности, и сканирование индекса. - Цена
- Это реальное, признанное нарушение нормальной формы, создающее ровно то второе место, где значение может разойтись, чего исходная модель данных старалась избежать. Пути записи приходится доверять поддержание копии в согласованности; ничто в схеме не обеспечивает это структурно, кроме аккуратной транзакционной дисциплины при загрузке и удалении.
Least-privilege lockdown: revoke everything, grant back three
Блокировка по минимуму прав: отозвать всё, выдать обратно три
- Options
- Patch the one bug that was found, members editing LoRA assignments they shouldn't, and move on · audit and fix
app_user's privileges table by table as issues surface · revoke all write privileges fromapp_userin one migration, including Postgres's own default grants, and add back exactly what the application needs. - Chosen
- Revoke, then grant back three privileges:
INSERTondocuments, plusUPDATErestricted to itsstatusandupdated_atcolumns;INSERToningestion_jobs;INSERTonaudit_log, restricted to the caller's own rows. Everything else is read-only or goes through aSECURITY DEFINERfunction. - Why
- Fixing the one reported bug led to a full privilege audit, and the audit found the bug was a symptom of a larger problem: a plain
UPDATE users SET is_admin = truesucceeded underapp_useron a cleanly migrated database, a direct escalation path around every identity check thedelete_documentanddelete_departmentfunctions relied on.embedding_modelsandadaptershad no RLS at all, lettingapp_userregister new models or adapter files outright. And Postgres's own default-privilege behavior had been quietly grantingapp_userwrite access to every new table as it was created, including tables from the pre-migrationschema.sqland even_sqlx_migrationsitself. - What it cost
- A revoke-first migration is a blunt instrument, and getting it wrong breaks the application rather than just leaving a gap open, so it had to be tested against every write path the app actually performs, not just the three intended ones. The test suite reproduces the original default-privilege drift before applying the fix, specifically to prove the regression test itself would have caught it.
- Варианты
- Исправить единственный найденный баг, члены отдела редактируют назначения LoRA, которых не должны касаться, и двигаться дальше · аудировать и чинить права
app_userтаблица за таблицей по мере всплытия проблем · отозвать все права на запись уapp_userодной миграцией, включая собственные гранты Postgres по умолчанию, и выдать обратно ровно то, что нужно приложению. - Выбрано
- Отозвать, затем выдать обратно три права:
INSERTвdocuments, плюсUPDATE, ограниченный колонкамиstatusиupdated_at;INSERTвingestion_jobs;INSERTвaudit_log, ограниченный собственными строками вызывающего. Всё остальное только для чтения или идёт через функциюSECURITY DEFINER. - Почему
- Исправление одного сообщённого бага привело к полному аудиту прав, и аудит нашёл, что баг был симптомом более крупной проблемы: обычный
UPDATE users SET is_admin = trueуспешно выполнялся подapp_userна чисто мигрированной базе, прямой путь эскалации в обход всех проверок личности, на которые опираются функцииdelete_documentиdelete_department. Уembedding_modelsиadaptersне было RLS вообще, что позволялоapp_userнапрямую регистрировать новые модели или файлы адаптеров. А собственное поведение Postgres по умолчанию тихо выдавалоapp_userправо записи на каждую новую таблицу по мере её создания, включая таблицы из досистемногоschema.sqlи даже сам_sqlx_migrations. - Цена
- Миграция с отзывом прав наперёд, инструмент грубый, и ошибиться в ней значит сломать приложение, а не просто оставить брешь открытой, так что её пришлось тестировать против каждого реального пути записи приложения, а не только трёх задуманных. Набор тестов воспроизводит исходный дрейф прав по умолчанию перед применением исправления, специально чтобы доказать, что регрессионный тест действительно бы его поймал.
PostgreSQL self-bundled, not Docker
PostgreSQL встроен в комплект, не Docker
- Options
- Require Docker, matching the existing development setup · require a separately-installed PostgreSQL plus a separately-built pgvector · self-bundle PostgreSQL and pgvector inside the app and manage the server process directly.
- Chosen
- Self-bundle. The app ships PostgreSQL 18 (an EDB build) and pgvector 0.8.3, self-compiled via MSVC, trimmed by dependency-graph analysis to 61MB on disk and a 24.5MB archive. On first run it calls
initdbin the app's own data directory, locale set toC.UTF-8via--locale-provider=builtinso Cyrillic text sorts and lowercases correctly regardless of the host machine's locale, authentication set toscram-sha-256, and the server bound only to127.0.0.1on a free port it picks itself. - Why
- Docker was the existing development setup, and it needs WSL2 and administrator rights on Windows, both non-starters for a business with no IT department. A separately-installed PostgreSQL plus a self-built pgvector shifts the same problem onto the person installing it rather than solving it.
- What it cost
- The app now owns a server process lifecycle it didn't before:
postgres.exeis run directly, withCREATE_NO_WINDOW, rather than throughpg_ctlfrom a console, after a console-tied server was found to crash with0xC0000142when its console closed. Trimming the binary took real investigative work,dumpbin /dependentsagainst the real dependency graph, not a guess, and it wasn't perfect the first time: an early trim droppedpg_trgm, which migration 001 needed despite the project having no trigram indexes.
- Варианты
- Требовать Docker, как в существующей настройке разработки · требовать отдельно устанавливаемый PostgreSQL плюс отдельно собранный pgvector · встроить PostgreSQL и pgvector внутрь приложения и управлять серверным процессом напрямую.
- Выбрано
- Встроить в комплект. Приложение поставляет PostgreSQL 18 (сборка EDB) и pgvector 0.8.3, собранный самостоятельно через MSVC, урезанный анализом графа зависимостей до 61 МБ на диске и 24,5 МБ в архиве. При первом запуске оно вызывает
initdbв собственной директории данных приложения, локаль установлена вC.UTF-8через--locale-provider=builtin, так что кириллица сортируется и приводится к нижнему регистру верно независимо от локали хост-машины, аутентификацияscram-sha-256, сервер слушает только127.0.0.1на свободном порту, который выбирает сам. - Почему
- Docker был существующей настройкой разработки, и на Windows ему нужны WSL2 и права администратора, оба варианта не подходят бизнесу без IT-отдела. Отдельно устанавливаемый PostgreSQL плюс самостоятельно собранный pgvector перекладывают ту же проблему на человека, устанавливающего приложение, вместо того чтобы её решить.
- Цена
- Теперь приложение владеет жизненным циклом серверного процесса, которого раньше не было:
postgres.exeзапускается напрямую, сCREATE_NO_WINDOW, а не черезpg_ctlиз консоли, после того как сервер, привязанный к консоли, обнаружился падающим с0xC0000142при закрытии консоли. Урезание бинарника потребовало реальной исследовательской работы,dumpbin /dependentsпо реальному графу зависимостей, не догадка, и с первого раза не получилось идеально: раннее урезание убралоpg_trgm, нужный миграции 001, несмотря на отсутствие в проекте trigram-индексов.
Calculations over spreadsheets: the model writes a plan, never SQL
Расчёты по таблицам: модель пишет план, никогда SQL
- Options
- Let the model write SQL directly against the spreadsheet tables · load the relevant rows into the Rust core's memory and compute there · have the model produce a structured plan inside a schema built from the real columns, and let the Rust core compile and run it.
- Chosen
- The plan. Each ingested spreadsheet is stored twice: as ordinary RAG chunks for retrieval, and as structured
sheet_tablesplussheet_rows(JSONB), written in the same ingestion transaction so a document only becomes ready with both or neither. Hybrid retrieval surfaces up to three candidate tables; the model is shown only those tables' real columns, types, frequent values, and ranges, and answers inside a JSON schema built from exactly those columns, sollama-server's grammar-constrained decoding makes it structurally impossible to name a column that doesn't exist. The Rust core independently re-validates the plan against the real schema regardless, and compiles it into one parameterized query, column choices and values passed as bind parameters, the SQL text itself built only from fixed fragments, never string-interpolated. The model only paraphrases the already-computed numbers; it never recalculates them. - Why
- Letting a model write SQL directly means trusting arbitrary generated SQL against a live database, on top of a model too small to be reliably correct about it. Loading rows into the Rust core's memory to compute there solves the trust problem but loses row-level RLS entirely and would mean shipping up to 50,000 rows across the boundary just to answer one question.
- What it cost
- This pushes real complexity into schema inference (majority-vote column typing, a "closed" flag for text columns whose values fall in a short frequent list) and into the core's independent validation pass, which has to reject a plan the model produced even after grammar constraints already ruled out most of the obviously invalid ones. Verified against real SQL on a 2,000-row and a 50,000-row registry, every sampled number matched exactly; the aggregate query itself runs in 21 milliseconds under RLS, with most of the 1.3 to 3.1 second response time spent on the planner model's own latency, not the database.
- Варианты
- Позволить модели писать SQL напрямую по таблицам электронных таблиц · загрузить нужные строки в память Rust-ядра и считать там · заставить модель составлять структурированный план внутри схемы, построенной из реальных колонок, и позволить Rust-ядру компилировать и выполнять его.
- Выбрано
- План. Каждая загруженная электронная таблица хранится дважды: как обычные чанки для RAG-поиска и как структурированные
sheet_tablesплюсsheet_rows(JSONB), записываемые в той же транзакции загрузки, так что документ становится готовым только вместе с обеими или ни с одной. Гибридный поиск поднимает до трёх таблиц-кандидатов; модели показывают только реальные колонки, типы, частые значения и диапазоны этих таблиц, и она отвечает внутри JSON-схемы, построенной ровно из этих колонок, так что ограниченное грамматикой декодированиеllama-serverделает структурно невозможным назвать несуществующую колонку. Rust-ядро независимо перепроверяет план по реальной схеме в любом случае и компилирует его в один параметризованный запрос, выбор колонок и значения передаются как связанные параметры, сам текст SQL собирается только из фиксированных фрагментов, никогда не через интерполяцию строк. Модель лишь пересказывает уже вычисленные числа своими словами; она никогда не пересчитывает их заново. - Почему
- Позволить модели писать SQL напрямую означает доверять произвольному сгенерированному SQL против живой базы, поверх модели, слишком маленькой, чтобы быть надёжно в этом правой. Загрузка строк в память Rust-ядра для вычислений там решает проблему доверия, но полностью теряет построчный RLS и означала бы передачу до 50 000 строк через границу ради ответа на один вопрос.
- Цена
- Это переносит реальную сложность на вывод схемы (типизация колонок голосованием большинства, флаг «закрытой» текстовой колонки, чьи значения укладываются в короткий частый список) и на независимую проверку ядра, которому приходится отклонять план, составленный моделью, даже после того, как ограничения грамматики уже отсеяли большинство явно невалидных вариантов. Проверено против реального SQL на реестрах из 2 000 и 50 000 строк, каждое выборочное число совпало точно; сам агрегирующий запрос выполняется за 21 миллисекунду под RLS, при этом основная часть времени ответа, от 1,3 до 3,1 секунды, уходит на собственную задержку модели-планировщика, а не на базу данных.
The plan travels through the same set of hands on every question, retrieval finds candidate tables, the model proposes a plan inside a constrained schema, the core validates and compiles it independently, Postgres runs one parameterized aggregate under the same RLS policy as everything else.
План проходит через один и тот же набор рук на каждый вопрос: поиск находит таблицы-кандидаты, модель предлагает план внутри ограниченной схемы, ядро независимо проверяет и компилирует его, Postgres выполняет один параметризованный агрегат под той же политикой RLS, что и всё остальное.
05 · Data model
05 · Модель данных
Chunks carry their own department_id, on purpose
The schema has grown past its original shape, spreadsheet ingestion alone added two tables, and the section below covers the entities the department boundary and the retrieval path actually touch; everything else (audit logs, adapter version history, job metadata) hangs off these.
chunks несёт собственный department_id, намеренно
Схема выросла за пределы исходной формы, только загрузка электронных таблиц добавила две таблицы, и раздел ниже покрывает сущности, которых действительно касаются граница отдела и путь поиска; всё остальное (журналы аудита, история версий адаптеров, метаданные задач) висит на них.
department_id column that the RLS policy filters on directly, chunks included, denormalized on purpose (ADR-0009). chunk_embeddings, embedding_models, and the spreadsheet tables sheet_tables/sheet_rows are omitted here for space; all three are described below.department_id, по которой напрямую фильтрует политика RLS, включая chunks, продублированную намеренно (ADR-0009). chunk_embeddings, embedding_models и таблицы электронных таблиц sheet_tables/sheet_rows здесь опущены ради места, все три описаны ниже.The RLS policy pattern is uniform across every scoped table:
Паттерн политики RLS единообразен для каждой ограниченной таблицы:
ALTER TABLE chunks ENABLE ROW LEVEL SECURITY;
ALTER TABLE chunks FORCE ROW LEVEL SECURITY;
CREATE POLICY department_isolation ON chunks
USING (department_id = ANY ((SELECT current_user_department_ids())::uuid[]));
-- the (SELECT ...) wrapper forces a one-time InitPlan,
-- not a function call repeated for every row scanned
SET LOCAL app.current_user_id = '...';
chunks and chunk_embeddings both carry their own department_id column rather than inheriting scope through a join to documents, a deliberate, documented departure from third normal form (ADR-0009): the RLS predicate above and the vector index's nearest-neighbor prefilter both need department on the exact row being scanned, on the hottest path in the system, and joining out to documents on every row was rejected for putting a join in that loop. chunk_embeddings is keyed by (chunk_id, embedding_model_id) against an embedding_models registry (name, dimension), with dimension fixed at 768 so one shared index serves every registered model; re-embedding a chunk with a stronger model later becomes additive, not destructive. Spreadsheet ingestion adds two more entities, sheet_tables (inferred column types and stats per sheet) and sheet_rows (the data itself, as JSONB), written in the same transaction as the chunk rows for that document so a document never ends up ready with one but not the other. Document and department deletion follow the same pattern throughout: content is physically erased, chunks and chunk_embeddings cascade-deleted in the same transaction, while the parent row becomes a tombstone, deleted_at and deleted_by set, title and hash retained, so audit-log references stay valid.
chunks и chunk_embeddings обе несут собственную колонку department_id, вместо того чтобы наследовать область через join к documents, намеренное, задокументированное отступление от третьей нормальной формы (ADR-0009): и предикату RLS выше, и предфильтру ближайших соседей векторного индекса нужен отдел прямо на сканируемой строке, на самом горячем пути системы, а join к documents на каждой строке был отклонён за то, что вставляет join в этот цикл. chunk_embeddings имеет ключ (chunk_id, embedding_model_id) по реестру embedding_models (имя, размерность), с размерностью, зафиксированной на 768, так что один общий индекс обслуживает каждую зарегистрированную модель; повторное вычисление эмбеддинга чанка более сильной моделью позже становится добавлением, а не разрушением. Загрузка электронных таблиц добавляет ещё две сущности, sheet_tables (типы колонок и статистика по каждому листу, выведенные автоматически) и sheet_rows (сами данные, в виде JSONB), записываемые в той же транзакции, что и строки чанков для этого документа, так что документ никогда не оказывается готовым с одной из них, но не с другой. Удаление документа и удаление отдела следуют одному и тому же паттерну повсюду: контент стирается физически, chunks и chunk_embeddings удаляются каскадом в той же транзакции, а родительская строка становится надгробием, deleted_at и deleted_by выставлены, название и хэш сохранены, так что ссылки журнала аудита остаются валидными.
06 · Hard problems
06 · Трудные места
An unrelated bug fix surfaced a privilege escalation path
Fixing a minor bug, department members could edit LoRA adapter assignments they shouldn't have been able to, led to a full privilege audit of the app_user role. The audit found three separate issues. A plain UPDATE users SET is_admin = true succeeded under app_user on a cleanly migrated database, a direct path around every identity check the document and department deletion functions relied on. embedding_models and adapters had no RLS at all, so app_user could register new models or adapter files outright. And Postgres's own default-privilege behavior had been granting app_user write access to every new table automatically as it was created, including tables left over from the schema predating the migration system. One migration revokes all write privileges from app_user, including the inherited defaults, and grants back exactly three: INSERT on documents plus a restricted UPDATE, INSERT on ingestion_jobs, and INSERT on audit_log restricted to the caller's own rows. A test suite reproduces the original drift before applying the fix, to confirm the regression test would actually have caught it, then checks twelve separate escalation attempts against the fixed schema.
Dormant RLS policies were adding real latency, not just risk
A latency investigation on a 50,000-row ingestion benchmark traced search time, 2.2 seconds, to two causes layered on top of each other. The membership-check predicate ran as a subquery evaluated once per row instead of once per query, and a set of dormant policies left over from an earlier schema were still active and still being evaluated on every scan, ORed together with the current ones by Postgres's own policy-combination rules. Isolating the cost step by step measured 257 milliseconds with the original policies active, 20 milliseconds with them stripped entirely, and 0.08 milliseconds on a bare index with no RLS at all. The fix rewrote the predicate so Postgres treats the membership check as a one-time InitPlan rather than a per-row function call, and a migration found and removed the dormant policies by the database functions they call rather than by name, since names vary between databases that predate the migration system and ones that don't. Search dropped to 220 milliseconds; a before-and-after visibility fingerprint across eight tables for four test users confirmed removing the dormant layer changed nothing about who could see what.
PDF word breaks needed a document-wide heuristic, not a fixed threshold
Text extraction from PDFs uses pdf-extract's default word-boundary logic, glyph gaps wider than roughly one em count as a space. Chromium-printed PDFs broke that assumption: the renderer's inter-glyph gaps inside real words measured wider than an actual space elsewhere in the same document, so words split apart, "календарных" became "к алендарных," "t o HR" where "to HR" should have been. No single fixed threshold could separate a false break from a real one, because the same document contained both kinds of gap at overlapping sizes. The fix measures the document itself first: if real, unambiguous spaces make up a meaningful share of its characters, roughly five percent or more, which almost every PDF generator produces, only those count as word breaks and glyph geometry is ignored entirely. If a document has essentially no real spaces, some TeX-based tools position every glyph individually, geometric gaps become the fallback signal instead. A separate, much wider gap on the same line is treated as a column boundary, preserved as a tab, rather than a word break. Verified against a real Chromium-printed PDF fixture, synthetic PDFs built with exactly known glyph geometry, and real DOCX quirks like tracked changes and deleted text.
Исправление несвязанного бага вскрыло путь эскалации привилегий
Исправление мелкого бага, члены отдела могли редактировать назначения LoRA-адаптеров, которых не должны были касаться, привело к полному аудиту прав роли app_user. Аудит нашёл три отдельные проблемы. Обычный UPDATE users SET is_admin = true успешно выполнялся под app_user на чисто мигрированной базе, прямой путь в обход всех проверок личности, на которые опирались функции удаления документов и отделов. У embedding_models и adapters не было RLS вообще, так что app_user мог напрямую регистрировать новые модели или файлы адаптеров. А собственное поведение Postgres по умолчанию автоматически выдавало app_user право записи на каждую новую таблицу по мере её создания, включая таблицы, оставшиеся от схемы, предшествующей системе миграций. Одна миграция отзывает все права на запись у app_user, включая унаследованные по умолчанию, и выдаёт обратно ровно три: INSERT в documents плюс ограниченный UPDATE, INSERT в ingestion_jobs и INSERT в audit_log, ограниченный собственными строками вызывающего. Набор тестов воспроизводит исходный дрейф перед применением исправления, чтобы подтвердить, что регрессионный тест действительно бы его поймал, затем проверяет двенадцать отдельных попыток эскалации против исправленной схемы.
Спящие политики RLS добавляли реальную задержку, не только риск
Расследование задержки на бенчмарке загрузки из 50 000 строк проследило время поиска, 2,2 секунды, до двух причин, наложенных друг на друга. Предикат проверки членства выполнялся как подзапрос, вычисляемый на каждую строку вместо одного раза на запрос, а набор спящих политик, оставшихся от более ранней схемы, всё ещё был активен и всё ещё вычислялся при каждом сканировании, объединяемый через OR с текущими политиками собственными правилами Postgres. Выделение цены по шагам дало 257 миллисекунд с исходными активными политиками, 20 миллисекунд при их полном удалении и 0,08 миллисекунды на голом индексе вовсе без RLS. Исправление переписало предикат так, чтобы Postgres воспринимал проверку членства как разовый InitPlan, а не как вызов функции на каждую строку, и миграция нашла и удалила спящие политики по функциям базы данных, которые они вызывают, а не по имени, поскольку имена различаются между базами, предшествующими системе миграций, и теми, что нет. Поиск упал до 220 миллисекунд; отпечаток видимости до и после по восьми таблицам для четырёх тестовых пользователей подтвердил, что удаление спящего слоя не изменило ничего в том, кто что видит.
Границы слов в PDF потребовали эвристики по всему документу, а не фиксированного порога
Извлечение текста из PDF использует логику границ слов pdf-extract по умолчанию: зазоры между глифами шире примерно одного em считаются пробелом. PDF, напечатанные Chromium, ломали это допущение: зазоры между глифами рендерера внутри реальных слов оказывались шире, чем настоящий пробел в другом месте того же документа, так что слова распадались, «календарных» превращалось в «к алендарных», «t o HR» там, где должно было быть «to HR». Ни один фиксированный порог не мог отделить ложный разрыв от настоящего, потому что один и тот же документ содержал оба вида зазоров пересекающихся размеров. Исправление сначала измеряет сам документ: если настоящие, однозначные пробелы составляют заметную долю его символов, примерно пять процентов или больше, что даёт почти любой генератор PDF, только они считаются границами слов, а геометрия глифов полностью игнорируется. Если в документе практически нет настоящих пробелов, некоторые инструменты на основе TeX расставляют каждый глиф по отдельности, геометрические зазоры становятся запасным сигналом вместо этого. Отдельный, намного более широкий зазор на той же строке трактуется как граница столбца, сохраняется как табуляция, а не как разрыв слова. Проверено на реальном фикстуре PDF, напечатанном Chromium, синтетических PDF с точно известной геометрией глифов и реальными особенностями DOCX вроде отслеживаемых изменений и удалённого текста.
07 · Verification
07 · Верификация
Isolation, privilege, and the spreadsheet-calculation claim were each tested from outside the application
- The RLS predicate rewrite verified with a before-and-after visibility fingerprint, row counts and id hashes across eight tables for four test users, confirming the InitPlan rewrite and dormant-policy removal changed performance without changing who can see what.
- The privilege lockdown verified by reproducing the original default-privilege drift on a fresh migration, then confirming the fix, then checking twelve separate escalation attempts fail against the locked-down schema.
- The self-bundled PostgreSQL install verified end to end on a clean Windows 11 machine, no Docker, no existing Postgres install, a cleared PATH, running all fifteen migrations plus the RLS, retrieval-ranking, and table-aggregation test suites.
- The spreadsheet-calculation claim verified against real SQL run directly, not through the model, on a 2,000-row and a 50,000-row registry; every sampled number matched exactly, and the aggregate query itself measured at 21 milliseconds under RLS.
- The hardware-adaptive inference engine verified on an RTX 4050 laptop: correct CUDA detection after a driver-string parsing fix (newer NVIDIA drivers label it CUDA UMD Version rather than CUDA Version), a resumable download confirmed to continue from the exact byte offset after a mid-download app restart, and Vulkan producing identical search and calculation results to CUDA at comparable token throughput.
Изоляция, привилегии и заявление о расчётах по таблицам проверялись каждое извне приложения
- Переписывание предиката RLS проверено отпечатком видимости до и после, счётчики строк и хэши идентификаторов по восьми таблицам для четырёх тестовых пользователей, подтвердившим, что переписывание в InitPlan и удаление спящих политик изменили производительность, не изменив то, кто что видит.
- Блокировка прав проверена воспроизведением исходного дрейфа прав по умолчанию на свежей миграции, затем подтверждением исправления, затем проверкой двенадцати отдельных попыток эскалации, отклоняемых против заблокированной схемы.
- Установка встроенного PostgreSQL проверена от начала до конца на чистой машине с Windows 11, без Docker, без существующей установки Postgres, с очищенным PATH, с прогоном всех пятнадцати миграций плюс наборов тестов RLS, ранжирования поиска и агрегации таблиц.
- Заявление о расчётах по таблицам проверено против реального SQL, выполненного напрямую, не через модель, на реестрах из 2 000 и 50 000 строк; каждое выборочное число совпало точно, а сам агрегирующий запрос замерен на 21 миллисекунде под RLS.
- Аппаратно-адаптивный движок инференса проверен на ноутбуке с RTX 4050: верное определение CUDA после исправления разбора строки драйвера (более новые драйверы NVIDIA обозначают её как CUDA UMD Version, а не CUDA Version), возобновляемая загрузка, подтверждённо продолжающаяся с точного байтового смещения после перезапуска приложения посреди загрузки, и Vulkan, дающий результаты поиска и расчётов, идентичные CUDA, при сравнимой скорости токенов.
08 · Retrospective
08 · Ретроспектива
If I rebuilt it
- An automated RLS regression suite that runs on every migration from the start, not one added after the dormant-policy layer had already been sitting there long enough to cost 257 milliseconds a query. Both the privilege-escalation finding and the dormant-policy finding are the kind of regression a CI-run isolation test would have caught immediately instead of during a manual audit.
- A recurring check for Postgres's own default-privilege grants specifically, not just for policies. The most serious finding here wasn't a bug in application logic at all; it was Postgres quietly doing exactly what it's documented to do, granting new tables to
app_userby default, and nobody had reason to look for that until an unrelated bug forced the audit. - The chat model's license checked before building months of work around it, not during distribution prep. Discovering late that Qwen2.5-3B-Instruct sits under a non-commercial license meant swapping to Qwen3-4B under real deployment pressure, and the swap itself surfaced new plan-validation gaps the smaller model change introduced.
- Self-bundling the database sounded like scope creep for a project this size when it first came up, and I'd defend the decision again: it's directly downstream of the requirement that a business with no IT department has to be able to install this without Docker, a console, or an administrator account, and there was no lighter way to satisfy that.
- The framing started narrower than the eventual target: the early ADRs describe regulated, GDPR-bound, IP-sensitive organizations, and the small-business framing came later, once it became clear those two audiences share the same technical requirement, on-prem, department-isolated, no cloud dependency, but for different reasons, cost and access to IT staff rather than regulation. The technical decisions didn't change; which buyer they were actually being made for got clearer over time.
Если бы строил заново
- Автоматический набор регрессионных тестов RLS, запускаемый при каждой миграции с самого начала, а не добавленный после того, как слой спящих политик уже пролежал достаточно долго, чтобы стоить 257 миллисекунд на запрос. И находка с эскалацией привилегий, и находка со спящими политиками относятся к тому типу регрессии, которую CI-тест изоляции поймал бы немедленно, а не во время ручного аудита.
- Регулярная проверка именно собственных грантов прав Postgres по умолчанию, не только политик. Самая серьёзная находка здесь была вовсе не багом в логике приложения; это был Postgres, тихо делающий ровно то, что задокументировано, выдающий новым таблицам права
app_userпо умолчанию, и ни у кого не было причины это искать, пока несвязанный баг не вынудил провести аудит. - Лицензия чат-модели проверена бы до месяцев работы, построенных вокруг неё, а не во время подготовки к дистрибуции. Позднее обнаружение, что Qwen2.5-3B-Instruct находится под некоммерческой лицензией, означало замену на Qwen3-4B под реальным давлением сроков поставки, и сама замена вскрыла новые пробелы в проверке плана, которые внесло уменьшение модели.
- Встраивание базы данных в комплект поначалу звучало как разрастание объёма для проекта такого размера, когда это впервые всплыло, и я бы снова отстоял это решение: оно прямое следствие требования, что бизнес без IT-отдела должен уметь установить это без Docker, консоли или учётной записи администратора, и более лёгкого способа удовлетворить это не было.
- Позиционирование начиналось уже, чем итоговая цель: ранние ADR описывают регулируемые, связанные GDPR, чувствительные к интеллектуальной собственности организации, а формулировка для малого бизнеса появилась позже, когда стало ясно, что у этих двух аудиторий одно и то же техническое требование, on-prem, изоляция по отделам, отсутствие облачной зависимости, но по разным причинам, стоимость и доступ к IT-персоналу, а не регулирование. Технические решения не изменились; для какого покупателя они на самом деле принимались, со временем стало яснее.