Своя база знаний для ИИ-бота: инструкция администратору
Полная настройка режима «материалы на своём сервере» - от пустой машины до работающего адреса. Рассчитана на самостоятельную настройку без нашего участия. Исходная точка - пустой сервер с чистой Ubuntu. Времени около часа, из них двадцать минут занимает скачивание модели.
0. Что понадобится
| Что | Минимум | Комментарий |
|---|---|---|
| Сервер | 2 ядра, 4 ГБ памяти, 20 ГБ диска | 4 ГБ - для векторного поиска. Для полнотекстового хватит 2 ГБ |
| ОС | Ubuntu 22.04 LTS или 24.04 LTS | ubuntu.com/download/server |
| Домен | поддомен вида kb.вашдомен.ru | A-запись на адрес сервера, мы ходим только по https |
| Учётная запись CyberAI | с подтверждённой почтой | cyberai.help |
Необходимый софт
| Компонент | Зачем | Где взять |
|---|---|---|
| PostgreSQL 14+ | хранение кусков материалов | пакет postgresql, postgresql.org |
| pgvector 0.8 | векторный поиск в PostgreSQL | github.com/pgvector/pgvector - сборка из исходников или пакет postgresql-16-pgvector |
| Python 3.10+ | сервис поиска | пакет python3-venv |
| FastAPI и Uvicorn | http-сервис | fastapi.tiangolo.com, ставится через pip |
| psycopg 3 | драйвер PostgreSQL | psycopg.org |
| sentence-transformers | векторизация текста | sbert.net |
Модель BAAI/bge-m3 | сами векторы: 1024 значения, русский язык | huggingface.co/BAAI/bge-m3, около 2,2 ГБ |
| nginx и certbot | адрес по https | certbot.eff.org |
Видеокарта не нужна. Векторизация материалов делается один раз при загрузке, а на вопрос покупателя считается один короткий вектор: 50-150 мс на обычном процессоре.
1. Подготовка сервера
apt-get update && apt-get -y upgrade
apt-get install -y python3-venv python3-pip postgresql postgresql-contrib \
nginx certbot python3-certbot-nginx ufw git build-essential \
postgresql-server-dev-allБрандмауэр закрываем сразу:
ufw allow 22/tcp ufw allow 80,443/tcp ufw --force enable ufw status # ожидаем: Status: active, три правила
Проверка версии базы - нужна 14 или новее:
psql --version # ожидаем: psql (PostgreSQL) 16.x
2. pgvector
Готовый пакет (Ubuntu 24.04, PostgreSQL 16):
apt-get install -y postgresql-16-pgvector
Если пакета нет - сборка из исходников, пять минут:
git clone --branch v0.8.0 https://github.com/pgvector/pgvector.git /tmp/pgvector make -C /tmp/pgvector make -C /tmp/pgvector install
3. База и таблица
sudo -u postgres psql -c "CREATE USER kb WITH PASSWORD 'ЗАМЕНИТЕ-НА-СВОЙ';" sudo -u postgres psql -c "CREATE DATABASE kb OWNER kb;" sudo -u postgres psql -d kb -c "CREATE EXTENSION IF NOT EXISTS vector;"
Схема. Обратите внимание на priority: правки ответов кладутся с приоритетом и выигрывают у обычных материалов.
sudo -u postgres psql -d kb <<'SQL'
CREATE TABLE IF NOT EXISTS kb_chunks (
id bigserial PRIMARY KEY,
source text NOT NULL DEFAULT '', -- имя файла или раздела
title text NOT NULL DEFAULT '', -- заголовок раздела
content text NOT NULL, -- сам кусок, 300-800 символов
priority int NOT NULL DEFAULT 0, -- 10 у правок ответов
embedding vector(1024), -- NULL при полнотекстовом режиме
fts tsvector GENERATED ALWAYS AS (to_tsvector('russian', content)) STORED,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS kb_fts_idx ON kb_chunks USING gin(fts);
CREATE INDEX IF NOT EXISTS kb_vec_idx ON kb_chunks
USING hnsw (embedding vector_cosine_ops);
ALTER TABLE kb_chunks OWNER TO kb;
SQL4. Python и модель
mkdir -p /opt/kb
python3 -m venv /opt/kb/venv
/opt/kb/venv/bin/pip install --upgrade pip
/opt/kb/venv/bin/pip install fastapi "uvicorn[standard]" "psycopg[binary]" \
sentence-transformersСкачиваем модель заранее, чтобы первый запрос покупателя не ждал 2 ГБ:
/opt/kb/venv/bin/python - <<'PY'
from sentence_transformers import SentenceTransformer
m = SentenceTransformer("BAAI/bge-m3")
v = m.encode("проверка", normalize_embeddings=True)
print("модель готова, размерность:", len(v)) # ожидаем: 1024
PYМодель ложится в ~/.cache/huggingface. Если у сервера нет доступа к huggingface.co - скачайте на машине с доступом и перенесите этот каталог.
5. Индексатор: загрузка материалов
/opt/kb/index.py режет тексты на куски, считает векторы и кладёт в базу. Запускается при каждом обновлении материалов.
"""Загрузка материалов в базу знаний.
/opt/kb/venv/bin/python /opt/kb/index.py /path/к/документам [--replace]
Принимает .txt и .md. Режет по абзацам, склеивая до 300-800 символов, с
перекрытием: фраза на границе кусков иначе не найдётся ни в одном из них.
"""
import os, sys, glob
import psycopg
from sentence_transformers import SentenceTransformer
DSN = os.environ.get("KB_DSN", "postgresql://kb:ЗАМЕНИТЕ@localhost/kb")
MIN_LEN, MAX_LEN, OVERLAP = 300, 800, 100
def split(text: str) -> list[str]:
paras = [p.strip() for p in text.split("\n\n") if p.strip()]
chunks, buf = [], ""
for p in paras:
if len(buf) + len(p) + 1 <= MAX_LEN:
buf = (buf + "\n" + p).strip()
continue
if buf:
chunks.append(buf)
buf = (buf[-OVERLAP:] + "\n" + p).strip() if len(buf) > OVERLAP else p
else:
# Абзац длиннее потолка - режем по предложениям.
cur = ""
for s in p.replace("! ", "!|").replace("? ", "?|").replace(". ", ".|").split("|"):
if len(cur) + len(s) > MAX_LEN:
chunks.append(cur.strip()); cur = s
else:
cur += " " + s
buf = cur.strip()
if buf:
chunks.append(buf)
return [c for c in chunks if len(c) >= 40]
def main(path: str, replace: bool) -> None:
model = SentenceTransformer("BAAI/bge-m3")
files = sorted(glob.glob(os.path.join(path, "**", "*.*"), recursive=True))
files = [f for f in files if f.lower().endswith((".txt", ".md"))]
if not files:
print("не нашёл .txt и .md в", path); return
with psycopg.connect(DSN) as cn, cn.cursor() as cur:
if replace:
# Правки ответов не трогаем: их прислал наш сервис, и повторно
# они не приедут.
cur.execute("DELETE FROM kb_chunks WHERE priority = 0")
print("старые материалы удалены")
total = 0
for f in files:
text = open(f, encoding="utf-8", errors="replace").read()
parts = split(text)
if not parts:
continue
vecs = model.encode(parts, normalize_embeddings=True,
batch_size=8, show_progress_bar=False)
for part, vec in zip(parts, vecs):
cur.execute(
"INSERT INTO kb_chunks(source, title, content, embedding) "
"VALUES (%s, %s, %s, %s::vector)",
(os.path.basename(f), os.path.basename(f), part, vec.tolist()))
total += len(parts)
print(f"{os.path.basename(f)}: {len(parts)} кусков")
cn.commit()
print("готово, кусков всего:", total)
if __name__ == "__main__":
if len(sys.argv) < 2:
print(__doc__); sys.exit(1)
main(sys.argv[1], "--replace" in sys.argv)Запуск и проверка:
KB_DSN='postgresql://kb:ВАШ-ПАРОЛЬ@localhost/kb' \ /opt/kb/venv/bin/python /opt/kb/index.py /root/materials --replace sudo -u postgres psql -d kb -c \ "SELECT count(*) AS кусков, count(embedding) AS с_векторами FROM kb_chunks;"
Ожидаем, что оба числа совпадают и больше нуля.
6. Сервис поиска
/opt/kb/server.py:
"""Эндпоинт базы знаний для CyberAI: поиск фрагментов и приём правок."""
import hashlib, hmac, json, os
import psycopg
from fastapi import FastAPI, Header, HTTPException, Request
SECRET = os.environ["CYBERAI_SECRET"].encode()
DSN = os.environ["KB_DSN"]
VECTORS = os.environ.get("KB_VECTORS", "1") == "1"
if VECTORS:
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("BAAI/bge-m3")
app = FastAPI()
async def checked(r: Request, sig):
raw = await r.body()
mine = hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
if not sig or not hmac.compare_digest(mine, sig):
raise HTTPException(401, "bad signature")
return json.loads(raw)
@app.get("/knowledge/health")
def health():
with psycopg.connect(DSN) as cn, cn.cursor() as cur:
cur.execute("SELECT count(*), count(embedding) FROM kb_chunks")
n, v = cur.fetchone()
return {"ok": True, "chunks": n, "vectors": v, "mode": "vector" if VECTORS else "fts"}
@app.post("/knowledge/search")
async def search(r: Request, x_cyberai_signature: str = Header(None)):
d = await checked(r, x_cyberai_signature)
q, k = d["query"], int(d.get("top_k", 6))
with psycopg.connect(DSN) as cn, cn.cursor() as cur:
if VECTORS:
vec = model.encode(q, normalize_embeddings=True).tolist()
cur.execute(
"SELECT id, content, 1 - (embedding <=> %s::vector) AS score "
"FROM kb_chunks WHERE embedding IS NOT NULL "
"ORDER BY priority DESC, embedding <=> %s::vector LIMIT %s",
(vec, vec, k))
else:
cur.execute(
"SELECT id, content, ts_rank_cd(fts, websearch_to_tsquery('russian', %s)) "
"FROM kb_chunks WHERE fts @@ websearch_to_tsquery('russian', %s) "
"ORDER BY priority DESC, 3 DESC LIMIT %s", (q, q, k))
rows = cur.fetchall()
return {"chunks": [{"id": str(i), "content": c, "score": float(s or 0)}
for i, c, s in rows]}
@app.post("/knowledge/correction")
async def correction(r: Request, x_cyberai_signature: str = Header(None)):
d = await checked(r, x_cyberai_signature)
text = ("Проверенный ответ, исправлен человеком - отвечай по нему.\n"
"Вопрос клиента: " + d["question"] + "\nПравильный ответ: " + d["answer"])
with psycopg.connect(DSN) as cn, cn.cursor() as cur:
if VECTORS:
vec = model.encode(text, normalize_embeddings=True).tolist()
cur.execute("INSERT INTO kb_chunks(source, title, content, priority, embedding) "
"VALUES ('cyberai', 'правка', %s, 10, %s::vector)", (text, vec))
else:
cur.execute("INSERT INTO kb_chunks(source, title, content, priority) "
"VALUES ('cyberai', 'правка', %s, 10)", (text,))
cn.commit()
return {"ok": True}Секрет генерируем и сохраняем - он же понадобится в кабинете:
openssl rand -hex 24
Служба /etc/systemd/system/kb.service:
[Unit] Description=CyberAI knowledge endpoint After=network.target postgresql.service [Service] Environment=CYBERAI_SECRET=СЕКРЕТ-ИЗ-ПРЕДЫДУЩЕЙ-КОМАНДЫ Environment=KB_DSN=postgresql://kb:ВАШ-ПАРОЛЬ@localhost/kb Environment=KB_VECTORS=1 WorkingDirectory=/opt/kb ExecStart=/opt/kb/venv/bin/uvicorn server:app --host 127.0.0.1 --port 8080 --workers 2 Restart=always RestartSec=3 [Install] WantedBy=multi-user.target
systemctl daemon-reload && systemctl enable --now kb
sleep 20 && curl -s localhost:8080/knowledge/health
# ожидаем: {"ok":true,"chunks":N,"vectors":N,"mode":"vector"}Первый запуск занимает 15-30 секунд: модель грузится в память.
7. Адрес по https
/etc/nginx/sites-available/kb:
server {
server_name kb.вашдомен.ru;
client_max_body_size 1m;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_read_timeout 30s;
}
}ln -sf /etc/nginx/sites-available/kb /etc/nginx/sites-enabled/kb rm -f /etc/nginx/sites-enabled/default nginx -t && systemctl reload nginx certbot --nginx -d kb.вашдомен.ru --agree-tos -m почта@вашдомен.ru --redirect curl -s https://kb.вашдомен.ru/knowledge/health # то же, что на шаге 6
Ограничьте доступ нашим адресом - запросы приходят с 157.228.138.231:
ufw delete allow 80,443/tcp ufw allow 80/tcp # certbot обновляет сертификат ufw allow from 157.228.138.231 to any port 443 proto tcp ufw status
8. Проверка подписи своими руками
Не дожидаясь кабинета, проверьте оба эндпоинта - так вы отделите свои ошибки от наших:
/opt/kb/venv/bin/python - <<'PY'
import hashlib, hmac, json, urllib.request
SECRET = b"ВАШ-СЕКРЕТ"
URL = "https://kb.вашдомен.ru/knowledge/search"
body = json.dumps({"query": "условия доставки", "top_k": 3},
ensure_ascii=False).encode()
req = urllib.request.Request(URL, data=body, headers={
"Content-Type": "application/json",
"X-CyberAI-Signature": hmac.new(SECRET, body, hashlib.sha256).hexdigest()})
print(urllib.request.urlopen(req, timeout=15).read().decode()[:600])
PYОжидаем JSON со списком chunks. Пусто - либо материалы не про то, что спросили, либо индексатор не отработал.
9. Настройка в кабинете CyberAI
Раздел «Настройки бота» → вкладка «Для разработчиков», блок «Материалы на моём сервере»:
| Поле | Значение |
|---|---|
| Адрес | https://kb.вашдомен.ru/knowledge/search |
| Секрет | тот же, что в kb.service |
| Фрагментов на запрос | 6 |
| Таймаут | 3000 мс |
| Потолок объёма | 12000 символов |
Порядок: сначала «Проверить настройки», потом включать. Кнопка отправит на ваш сервер тестовый вопрос и покажет, что он вернул, сколько символов и за сколько миллисекунд.
После включения:
- наш поиск по вашему проекту не работает вовсе, материалы у нас не хранятся;
- обучение базы знаний у нас не производится и не оплачивается;
- правки ответов из «Диалогов» уезжают на ваш сервер;
- оплачиваются только ответы - в чате, мессенджерах, на отзывы и вопросы площадок. Во всех каналах одинаково.
10. Эксплуатация
Обновление материалов - повторный запуск индексатора. Ключ --replace удаляет прежние материалы, но не трогает правки:
KB_DSN='postgresql://kb:ВАШ-ПАРОЛЬ@localhost/kb' \ /opt/kb/venv/bin/python /opt/kb/index.py /root/materials --replace
Смена секрета - сначала в кабинете, потом в kb.service и systemctl restart kb. В обратном порядке несколько минут ответы будут отказами.
Резервная копия - обычный дамп, восстановление на любой сервер:
sudo -u postgres pg_dump -Fc kb > /root/kb-$(date +%F).dump
Наблюдение. Полезно проверять /knowledge/health своим мониторингом раз в пять минут: если сервис лёг, бот начнёт отвечать «не знаю», а покупатели - уходить, и узнаете вы об этом последним.
11. Что делать, когда бот отвечает «не знаю»
| Признак | Причина | Что сделать |
|---|---|---|
| «Проверить настройки» в кабинете: 0 фрагментов | Поиск не находит | Проверьте SELECT count(*) FROM kb_chunks, спросите словами из материалов |
| Ошибка 401 | Секрет разошёлся | Сверьте кабинет и kb.service, перезапустите службу |
| Таймаут на первом запросе после простоя | Модель выгрузилась из памяти | Поднимите таймаут до 5000 мс либо держите службу тёплой запросом к /knowledge/health раз в минуту |
| Ошибка соединения | Закрыт порт или наш адрес не разрешён | ufw status, разрешите 157.228.138.231 |
| Ответы невпопад | Куски слишком крупные или слишком мелкие | Целевой размер 300-800 символов, перекрытие 100 |
502 в наших журналах при правке | Не поднялся /knowledge/correction | Проверьте, что адрес правки соседний с поиском |
12. Что остаётся у нас, а что нет
Не хранится у нас: документы, куски, векторы, правки ответов - ничего из ваших материалов. Разметку и хранение делаете вы.
Хранится у нас: переписка с покупателями - на ней держатся «Диалоги» и разбор жалоб - и счётчики списаний, без них нет биллинга.
Уходит наружу в момент ответа: фрагменты, которые прислал ваш сервер, передаются языковой модели, иначе ответа не будет. Это передача на время запроса, а не хранение.
Честная формулировка режима: мы не храним ваши материалы. Не «ваши данные никуда не уходят».