rpi: tg — кэш векторов (npz) и служба tg-embed: поиск ~6 с вместо ~40
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
48c129615f
commit
3bdb75d465
+1
-1
@@ -299,7 +299,7 @@ Docker CE 29.9 + Compose v5.6 из репо download.docker.com (в Debian ст
|
|||||||
- Проект `tg_export` (Telethon) перенесён с Mac: код, юниты и установка — [`stacks/rpi-tg-export/`](../stacks/rpi-tg-export/README.md). На малине — **второй аккаунт владельца**, докачка **только текста** раз в сутки (`tg-export.timer`, 04:30, `run --no-media --archived`), выгрузка в `/mnt/backup/tg/` (tgexport:astra 2750/640 — Astra читает). Старая выгрузка первого аккаунта (~14 ГБ) — на Mac, не переносилась (решение владельца).
|
- Проект `tg_export` (Telethon) перенесён с Mac: код, юниты и установка — [`stacks/rpi-tg-export/`](../stacks/rpi-tg-export/README.md). На малине — **второй аккаунт владельца**, докачка **только текста** раз в сутки (`tg-export.timer`, 04:30, `run --no-media --archived`), выгрузка в `/mnt/backup/tg/` (tgexport:astra 2750/640 — Astra читает). Старая выгрузка первого аккаунта (~14 ГБ) — на Mac, не переносилась (решение владельца).
|
||||||
- Отдельный системный пользователь `tgexport` (без шелла): сессия `/var/lib/tg-export/tg.session` (600) = полный доступ к аккаунту — Astra её не видит; ключи приложения `/etc/tg-export/env` (root:tgexport 640, те же `api_id`/`api_hash`, что на Mac). Вход — владелец: `ssh -t rpi sudo -u tgexport tg-export login`.
|
- Отдельный системный пользователь `tgexport` (без шелла): сессия `/var/lib/tg-export/tg.session` (600) = полный доступ к аккаунту — Astra её не видит; ключи приложения `/etc/tg-export/env` (root:tgexport 640, те же `api_id`/`api_hash`, что на Mac). Вход — владелец: `ssh -t rpi sudo -u tgexport tg-export login`.
|
||||||
- Мониторинг — `backup-metrics backup_tg_export` → `BackupFailed`/`BackupStale` (>30 ч). ⚠️ Первый прогон может идти дольше суток из-за FloodWait.
|
- Мониторинг — `backup-metrics backup_tg_export` → `BackupFailed`/`BackupStale` (>30 ч). ⚠️ Первый прогон может идти дольше суток из-за FloodWait.
|
||||||
- **Поиск и сводка** (с 2026-10-18): докачка каждый час (было раз в сутки 04:30) + индекс `/mnt/backup/tg/index.db` (FTS5 + эмбеддинги potion-multilingual-128M, fastembed в venv, модели `/opt/tg-export/models`); `tg search` для Astra, темы `tg topic`, сводка `tg-report.timer` 08:00 Asia/Nicosia через Codex → очередь outbox. Навык и правила — astra-bot `11dd4fc`. Подробно — README стека. Вход во второй аккаунт (@mikpokrovsky) — 2026-10-18; выгружаются только 6 выбранных владельцем чатов (`--only`, см. README стека). ⚠️ 2026-10-18 18:45–18:51 по ошибке шла выгрузка всех групп и каналов (успела только группа Python, 35 500 сообщений) — остановлена, данные удалены по решению владельца. ⚠️ Таймер выгрузки **выключен** до вступления владельца в @cyprusdiy, @cymedic и Cyprus Car – Chat; сводка включена, без тем и индекса молчит.
|
- **Поиск и сводка** (с 2026-10-18): докачка каждый час (было раз в сутки 04:30) + индекс `/mnt/backup/tg/index.db` (FTS5 + эмбеддинги potion-multilingual-128M, fastembed в venv, модели `/opt/tg-export/models`); `tg search` для Astra, темы `tg topic`, сводка `tg-report.timer` 08:00 Asia/Nicosia через Codex → очередь outbox. Навык и правила — astra-bot `11dd4fc`. Служба `tg-embed` (astra) держит модель эмбеддингов в памяти для быстрых запросов (~6 с вместо ~40). Первая полная выгрузка 2026-10-18 19:49: 12 чатов, 303 тыс. сообщений с 2024-10-18, индекс 85 580 кусков, 1,2 ГБ на HDD. Подробно — README стека. Вход во второй аккаунт (@mikpokrovsky) — 2026-10-18; выгружаются только 6 выбранных владельцем чатов (`--only`, см. README стека). ⚠️ 2026-10-18 18:45–18:51 по ошибке шла выгрузка всех групп и каналов (успела только группа Python, 35 500 сообщений) — остановлена, данные удалены по решению владельца. ⚠️ Таймер выгрузки **выключен** до вступления владельца в @cyprusdiy, @cymedic и Cyprus Car – Chat; сводка включена, без тем и индекса молчит.
|
||||||
- Доступ: тексты — только для владельца; Оле и Поле закрыто (правило в `codex/AGENTS.md` и скилл `tg`, astra-bot `27bf8e6`), как почта.
|
- Доступ: тексты — только для владельца; Оле и Поле закрыто (правило в `codex/AGENTS.md` и скилл `tg`, astra-bot `27bf8e6`), как почта.
|
||||||
|
|
||||||
## Читалка books → Obsidian (с 2026-10-18)
|
## Читалка books → Obsidian (с 2026-10-18)
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ sudo systemctl enable --now tg-export.timer
|
|||||||
Сравнение на 8 тыс. сообщений групп 2026-10-18: potion 57 с, `paraphrase-multilingual-MiniLM-L12-v2` 915 с при
|
Сравнение на 8 тыс. сообщений групп 2026-10-18: potion 57 с, `paraphrase-multilingual-MiniLM-L12-v2` 915 с при
|
||||||
сопоставимом качестве поиска (MiniLM к тому же видит только ~128 токенов куска). e5-large/jina-v3 на Pi 4 —
|
сопоставимом качестве поиска (MiniLM к тому же видит только ~128 токенов куска). e5-large/jina-v3 на Pi 4 —
|
||||||
дни на историю. Сменить — `TG_EMB_MODEL` + переиндексация (удалить `index.db`).
|
дни на историю. Сменить — `TG_EMB_MODEL` + переиндексация (удалить `index.db`).
|
||||||
|
- **Скорость** (2026-10-18, 85 тыс. кусков): векторы всех кусков — `index.emb.npz` рядом с индексом (пересобирается после
|
||||||
|
индексации; из SQLite на HDD было ~40 с на запрос); модель держит служба **`tg-embed`** (astra, сокет
|
||||||
|
`/run/tg-embed/embed.sock` 0600, ~140 МБ RSS) — загрузка модели в процесс ~11 с (fastembed/ONNX; model2vec ещё
|
||||||
|
медленнее, 28 с — проверено, векторы те же). Итого `tg search` ~6 с. Нет службы — `tg` грузит модель сам.
|
||||||
- `tg search ЗАПРОС [--chat] [--since 90d] [-n]` — гибрид BM25 + косинус (RRF), куски со ссылками t.me
|
- `tg search ЗАПРОС [--chat] [--since 90d] [-n]` — гибрид BM25 + косинус (RRF), куски со ссылками t.me
|
||||||
(`t.me/<username>/<id>` или `t.me/c/<id>/<msg>`). ⚠️ Одна формулировка часто промахивается
|
(`t.me/<username>/<id>` или `t.me/c/<id>/<msg>`). ⚠️ Одна формулировка часто промахивается
|
||||||
(«посоветуйте спортзал» не нашло «фитнес в отелях») — Astra по навыку `tg` переформулирует 2–4 раза.
|
(«посоветуйте спортзал» не нашло «фитнес в отелях») — Astra по навыку `tg` переформулирует 2–4 раза.
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
tg report [--hours 24] [--dry] сводка по темам за период → владельцу в Telegram (--dry — только напечатать)
|
tg report [--hours 24] [--dry] сводка по темам за период → владельцу в Telegram (--dry — только напечатать)
|
||||||
tg stats сколько чатов/сообщений/кусков, когда обновлялось
|
tg stats сколько чатов/сообщений/кусков, когда обновлялось
|
||||||
tg index (сервис tg-export, юзер tgexport) — дописать в индекс новые сообщения выгрузки
|
tg index (сервис tg-export, юзер tgexport) — дописать в индекс новые сообщения выгрузки
|
||||||
|
tg serve (служба tg-embed, юзер astra) — держать модель в памяти, векторы запросов по unix-сокету
|
||||||
|
|
||||||
Индекс — /mnt/backup/tg/index.db (SQLite: FTS5 + эмбеддинги potion-multilingual-128M), пишет tgexport,
|
Индекс — /mnt/backup/tg/index.db (SQLite: FTS5 + эмбеддинги potion-multilingual-128M), пишет tgexport,
|
||||||
читают все из группы astra. Кусок = подряд идущие сообщения одного чата с паузами < 20 мин (до ~800 знаков).
|
читают все из группы astra. Кусок = подряд идущие сообщения одного чата с паузами < 20 мин (до ~800 знаков).
|
||||||
@@ -38,13 +39,44 @@ TZ = ZoneInfo("Asia/Nicosia")
|
|||||||
GAP = 20 * 60 # пауза, после которой начинается новый кусок
|
GAP = 20 * 60 # пауза, после которой начинается новый кусок
|
||||||
CHUNK_CHARS = int(os.environ.get("TG_CHUNK_CHARS", "800"))
|
CHUNK_CHARS = int(os.environ.get("TG_CHUNK_CHARS", "800"))
|
||||||
TG_MAX = 3800 # одно сообщение Telegram (лимит 4096) — сводку режем на части
|
TG_MAX = 3800 # одно сообщение Telegram (лимит 4096) — сводку режем на части
|
||||||
|
# векторы всех кусков одним файлом рядом с индексом: чтение 85 тыс. BLOB из SQLite на HDD — ~40 с на запрос,
|
||||||
|
# а np.load(mmap) — доли секунды; пересобирается после каждой индексации
|
||||||
|
CACHE = DB.with_suffix(".emb.npz")
|
||||||
|
|
||||||
_emb = None
|
_emb = None
|
||||||
|
SOCK = os.environ.get("TG_EMBED_SOCK", "/run/tg-embed/embed.sock")
|
||||||
|
|
||||||
|
|
||||||
def embed(texts):
|
def _embed_remote(texts):
|
||||||
|
"""Векторы от службы tg-embed (модель уже в памяти) — иначе None. Загрузка модели в процесс — ~11 с на Pi 4."""
|
||||||
|
import socket
|
||||||
|
import numpy as np
|
||||||
|
if not os.path.exists(SOCK):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s:
|
||||||
|
s.settimeout(30)
|
||||||
|
s.connect(SOCK)
|
||||||
|
s.sendall(json.dumps({"texts": texts}).encode() + b"\n")
|
||||||
|
buf = b""
|
||||||
|
while not buf.endswith(b"\n"):
|
||||||
|
part = s.recv(1 << 20)
|
||||||
|
if not part:
|
||||||
|
break
|
||||||
|
buf += part
|
||||||
|
r = json.loads(buf)
|
||||||
|
return np.frombuffer(bytes.fromhex(r["vecs"]), dtype=np.float32).reshape(r["n"], r["dim"])
|
||||||
|
except (OSError, ValueError, KeyError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def embed(texts, remote=True):
|
||||||
global _emb
|
global _emb
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
if remote and len(texts) <= 64:
|
||||||
|
v = _embed_remote(list(texts))
|
||||||
|
if v is not None:
|
||||||
|
return v
|
||||||
if _emb is None:
|
if _emb is None:
|
||||||
from fastembed import TextEmbedding
|
from fastembed import TextEmbedding
|
||||||
_emb = TextEmbedding(EMB_MODEL, cache_dir=MODELS, threads=4)
|
_emb = TextEmbedding(EMB_MODEL, cache_dir=MODELS, threads=4)
|
||||||
@@ -162,9 +194,24 @@ def cmd_index(a):
|
|||||||
total += len(new)
|
total += len(new)
|
||||||
c.execute("INSERT OR REPLACE INTO meta VALUES ('indexed', ?)", (dt.datetime.now(TZ).isoformat(timespec="seconds"),))
|
c.execute("INSERT OR REPLACE INTO meta VALUES ('indexed', ?)", (dt.datetime.now(TZ).isoformat(timespec="seconds"),))
|
||||||
c.commit()
|
c.commit()
|
||||||
|
if total or not CACHE.exists():
|
||||||
|
build_cache(c)
|
||||||
print(f"проиндексировано новых сообщений: {total}")
|
print(f"проиндексировано новых сообщений: {total}")
|
||||||
|
|
||||||
|
|
||||||
|
def build_cache(c):
|
||||||
|
import numpy as np
|
||||||
|
rows = c.execute("SELECT id, chat_id, ts1, emb FROM chunk ORDER BY id").fetchall()
|
||||||
|
if not rows:
|
||||||
|
return
|
||||||
|
tmp = CACHE.with_name(CACHE.name + ".tmp.npz")
|
||||||
|
np.savez(tmp, ids=np.array([r["id"] for r in rows], dtype=np.int64),
|
||||||
|
chat=np.array([r["chat_id"] for r in rows], dtype=np.int64),
|
||||||
|
ts=np.array([r["ts1"] for r in rows], dtype=np.int64),
|
||||||
|
emb=np.frombuffer(b"".join(r["emb"] for r in rows), dtype=np.float32).reshape(len(rows), -1))
|
||||||
|
os.replace(tmp, CACHE)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- поиск
|
# ---------------------------------------------------------------- поиск
|
||||||
|
|
||||||
def link(chat, msg_id):
|
def link(chat, msg_id):
|
||||||
@@ -216,16 +263,25 @@ def search(q, chat=None, since=None, n=12, min_sim=None):
|
|||||||
ranks[row["id"]] = ranks.get(row["id"], 0) + 1 / (60 + r)
|
ranks[row["id"]] = ranks.get(row["id"], 0) + 1 / (60 + r)
|
||||||
by_words.add(row["id"])
|
by_words.add(row["id"])
|
||||||
qv = embed([q])[0]
|
qv = embed([q])[0]
|
||||||
ids, mats = [], []
|
if CACHE.exists():
|
||||||
for row in c.execute(f"SELECT c.id, c.emb FROM chunk c WHERE {w}", args): # 384 float32 на кусок — в памяти ок
|
z = np.load(CACHE)
|
||||||
ids.append(row["id"])
|
ids, emb, mask = z["ids"], z["emb"], np.ones(len(z["ids"]), dtype=bool)
|
||||||
mats.append(row["emb"])
|
if since:
|
||||||
if ids:
|
mask &= z["ts"] >= since
|
||||||
m = np.frombuffer(b"".join(mats), dtype=np.float32).reshape(len(ids), -1)
|
if chat:
|
||||||
s = m @ qv
|
cids = [r[0] for r in c.execute("SELECT id FROM chat WHERE title LIKE ? OR username LIKE ?", (f"%{chat}%", f"%{chat}%"))]
|
||||||
for r, i in enumerate(np.argsort(-s)[:60]):
|
mask &= np.isin(z["chat"], cids)
|
||||||
sims[ids[i]] = float(s[i])
|
ids, emb = ids[mask], emb[mask]
|
||||||
ranks[ids[i]] = ranks.get(ids[i], 0) + 1 / (60 + r)
|
else: # кэша нет (старый индекс) — медленно, из базы
|
||||||
|
rows = c.execute(f"SELECT c.id, c.emb FROM chunk c WHERE {w}", args).fetchall()
|
||||||
|
ids = np.array([r["id"] for r in rows], dtype=np.int64)
|
||||||
|
emb = np.frombuffer(b"".join(r["emb"] for r in rows), dtype=np.float32).reshape(len(rows), -1) if rows else None
|
||||||
|
if len(ids):
|
||||||
|
s = emb @ qv
|
||||||
|
top_i = np.argpartition(-s, min(60, len(s) - 1))[:60]
|
||||||
|
for r, i in enumerate(top_i[np.argsort(-s[top_i])]):
|
||||||
|
sims[int(ids[i])] = float(s[i])
|
||||||
|
ranks[int(ids[i])] = ranks.get(int(ids[i]), 0) + 1 / (60 + r)
|
||||||
top = sorted(ranks, key=ranks.get, reverse=True)
|
top = sorted(ranks, key=ranks.get, reverse=True)
|
||||||
if min_sim is not None: # для сводки: найденное словами — всегда, по смыслу — только достаточно близкое
|
if min_sim is not None: # для сводки: найденное словами — всегда, по смыслу — только достаточно близкое
|
||||||
top = [i for i in top if i in by_words or sims.get(i, 0) >= min_sim]
|
top = [i for i in top if i in by_words or sims.get(i, 0) >= min_sim]
|
||||||
@@ -361,6 +417,30 @@ def cmd_report(a):
|
|||||||
print(f"сводка поставлена в очередь: {len(parts)} сообщ.")
|
print(f"сводка поставлена в очередь: {len(parts)} сообщ.")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_serve(a):
|
||||||
|
"""Служба tg-embed: модель в памяти, векторы запросов по unix-сокету (одна строка JSON → одна строка JSON)."""
|
||||||
|
import socketserver
|
||||||
|
embed(["прогрев"], remote=False)
|
||||||
|
|
||||||
|
class H(socketserver.StreamRequestHandler):
|
||||||
|
def handle(self):
|
||||||
|
try:
|
||||||
|
req = json.loads(self.rfile.readline())
|
||||||
|
v = embed(req["texts"][:64], remote=False)
|
||||||
|
out = {"n": v.shape[0], "dim": v.shape[1], "vecs": v.astype("float32").tobytes().hex()}
|
||||||
|
except Exception as e: # плохой запрос не роняет службу
|
||||||
|
out = {"error": str(e)}
|
||||||
|
self.wfile.write(json.dumps(out).encode() + b"\n")
|
||||||
|
|
||||||
|
if os.path.exists(SOCK):
|
||||||
|
os.unlink(SOCK)
|
||||||
|
old = os.umask(0o077) # сокет — только своему пользователю (astra)
|
||||||
|
srv = socketserver.UnixStreamServer(SOCK, H)
|
||||||
|
os.umask(old)
|
||||||
|
print(f"tg-embed: {EMB_MODEL} в памяти, сокет {SOCK}", flush=True)
|
||||||
|
srv.serve_forever()
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
p = argparse.ArgumentParser(prog="tg", description=__doc__.split("\n")[0])
|
p = argparse.ArgumentParser(prog="tg", description=__doc__.split("\n")[0])
|
||||||
sub = p.add_subparsers(dest="cmd", required=True)
|
sub = p.add_subparsers(dest="cmd", required=True)
|
||||||
@@ -373,6 +453,7 @@ def main():
|
|||||||
s = sub.add_parser("report"); s.add_argument("--hours", type=int, default=24); s.add_argument("--dry", action="store_true")
|
s = sub.add_parser("report"); s.add_argument("--hours", type=int, default=24); s.add_argument("--dry", action="store_true")
|
||||||
sub.add_parser("stats")
|
sub.add_parser("stats")
|
||||||
s = sub.add_parser("index"); s.add_argument("--quiet", action="store_true")
|
s = sub.add_parser("index"); s.add_argument("--quiet", action="store_true")
|
||||||
|
sub.add_parser("serve")
|
||||||
a = p.parse_args()
|
a = p.parse_args()
|
||||||
globals()["cmd_" + a.cmd](a)
|
globals()["cmd_" + a.cmd](a)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Telegram-поиск: модель эмбеддингов в памяти (векторы запросов tg search/report за доли секунды)
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
User=astra
|
||||||
|
Group=astra
|
||||||
|
# сокет /run/tg-embed/embed.sock, только astra (0700 каталог, 0600 сокет)
|
||||||
|
RuntimeDirectory=tg-embed
|
||||||
|
RuntimeDirectoryMode=0700
|
||||||
|
ExecStart=/opt/tg-export/venv/bin/python /opt/tg-export/tgrag.py serve
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=30
|
||||||
|
Nice=5
|
||||||
|
MemoryMax=1500M
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
Reference in New Issue
Block a user