Своя база знаний для ИИ-бота: инструкция администратору

Полная настройка режима «материалы на своём сервере» - от пустой машины до работающего адреса. Рассчитана на самостоятельную настройку без нашего участия. Исходная точка - пустой сервер с чистой Ubuntu. Времени около часа, из них двадцать минут занимает скачивание модели.

Смысл режима и то, что при нём остаётся у нас, описаны в статье Материалы на своём сервере. Здесь только команды.

0. Что понадобится

ЧтоМинимумКомментарий
Сервер2 ядра, 4 ГБ памяти, 20 ГБ диска4 ГБ - для векторного поиска. Для полнотекстового хватит 2 ГБ
ОСUbuntu 22.04 LTS или 24.04 LTSubuntu.com/download/server
Доменподдомен вида kb.вашдомен.ruA-запись на адрес сервера, мы ходим только по https
Учётная запись CyberAIс подтверждённой почтойcyberai.help

Необходимый софт

КомпонентЗачемГде взять
PostgreSQL 14+хранение кусков материаловпакет postgresql, postgresql.org
pgvector 0.8векторный поиск в PostgreSQLgithub.com/pgvector/pgvector - сборка из исходников или пакет postgresql-16-pgvector
Python 3.10+сервис поискапакет python3-venv
FastAPI и Uvicornhttp-сервисfastapi.tiangolo.com, ставится через pip
psycopg 3драйвер PostgreSQLpsycopg.org
sentence-transformersвекторизация текстаsbert.net
Модель BAAI/bge-m3сами векторы: 1024 значения, русский языкhuggingface.co/BAAI/bge-m3, около 2,2 ГБ
nginx и certbotадрес по httpscertbot.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;
SQL

4. 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. Что остаётся у нас, а что нет

Не хранится у нас: документы, куски, векторы, правки ответов - ничего из ваших материалов. Разметку и хранение делаете вы.

Хранится у нас: переписка с покупателями - на ней держатся «Диалоги» и разбор жалоб - и счётчики списаний, без них нет биллинга.

Уходит наружу в момент ответа: фрагменты, которые прислал ваш сервер, передаются языковой модели, иначе ответа не будет. Это передача на время запроса, а не хранение.

Честная формулировка режима: мы не храним ваши материалы. Не «ваши данные никуда не уходят».