From 39c807d4deb7cb2072b8bff090cdd101aafebb2e Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 30 Jul 2026 18:23:22 +0300 Subject: [PATCH] =?UTF-8?q?=D0=AD=D0=BC=D0=B1=D0=B5=D0=B4=D0=B4=D0=B5?= =?UTF-8?q?=D1=80=20bge-m3:=20=D1=81=D0=BC=D1=8B=D1=81=D0=BB=D0=BE=D0=B2?= =?UTF-8?q?=D0=BE=D0=B9=20=D0=BF=D0=BE=D0=B8=D1=81=D0=BA=20=D0=BD=D0=B0=20?= =?UTF-8?q?=D1=80=D1=83=D1=81=D1=81=D0=BA=D0=BE=D0=BC=20=D0=BD=D0=B0=D0=BA?= =?UTF-8?q?=D0=BE=D0=BD=D0=B5=D1=86=20=D1=80=D0=B0=D0=B7=D0=B4=D0=B5=D0=BB?= =?UTF-8?q?=D1=8F=D0=B5=D1=82=20=D1=81=D0=BC=D1=8B=D1=81=D0=BB=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit nomic-embed-text на русском не работал: в замере «велосипедная камера» набирала мультиметру 0.704 — больше, чем верный ответ на «паяльник» (0.691). Зазор −0.013, то есть мусор систематически обгонял правильное и порогом это не лечилось. bge-m3 на том же наборе: верные не ниже 0.537, мусор не выше 0.460, зазор +0.077. Префиксы задачи ему не нужны, поэтому обнулены. Порог откалиброван на настоящей базе, а не на придуманном наборе, и картина там честнее: абстрактное «чем посветить» набирает лампам 0.411, а посторонняя «детская коляска» — 0.535. Полностью не разделяются, поэтому SEMANTIC_MIN выставлен так, чтобы отсечь весь мусор ценой слабых догадок — уверенно показанное не то хуже честного «не нашёл». Точных запросов это не касается: они находятся по словам и тегам. Добавлен tools/reindex.py: смена эмбеддера требует полного пересчёта векторов, иначе смешанная база тихо портит поиск. Скрипт делает копию перед работой. Co-Authored-By: Claude Opus 5 --- .env.example | 4 +-- README.md | 32 +++++++++++++-------- hlamingo/config.py | 25 +++++++++------- hlamingo/search.py | 28 ++++++++++++------ tools/reindex.py | 71 ++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 127 insertions(+), 33 deletions(-) create mode 100644 tools/reindex.py diff --git a/.env.example b/.env.example index 41229eb..edb6a57 100644 --- a/.env.example +++ b/.env.example @@ -11,8 +11,8 @@ OLLAMA_HOST=http://localhost:11434 # Модель для эмбеддингов (поиск по смыслу). Необязательна — без неё # работает поиск по словам и тегам: -# ollama pull nomic-embed-text -# HLAMINGO_EMBED_MODEL=nomic-embed-text:latest +# ollama pull bge-m3 +# HLAMINGO_EMBED_MODEL=bge-m3 # Порт веб-интерфейса. # HLAMINGO_PORT=5000 diff --git a/README.md b/README.md index 8daa29b..4f88d29 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ ``` ollama pull qwen2.5:7b разбор фраз и теги -ollama pull nomic-embed-text поиск по смыслу (необязательно) +ollama pull bge-m3 поиск по смыслу (необязательно) ``` ## Установка @@ -62,7 +62,7 @@ start.cmd запускает сервер и печатает адреса |---|---|---| | `OLLAMA_HOST` | `http://localhost:11434` | адрес Ollama | | `HLAMINGO_CHAT_MODEL` | `qwen2.5:7b` | разбор фраз и теги | -| `HLAMINGO_EMBED_MODEL` | `nomic-embed-text:latest` | поиск по смыслу | +| `HLAMINGO_EMBED_MODEL` | `bge-m3` | поиск по смыслу | | `HLAMINGO_PORT` | `5000` | порт веб-интерфейса | | `HLAMINGO_DATA_DIR` | — | другая папка для данных и `.env`; используется тестами | @@ -168,20 +168,28 @@ Photo Picker, в котором опции «Камера» нет вообще 3. **По смыслу (эмбеддинги)** — только если по словам ничего не нашлось, с высоким порогом и с пометкой «похоже по смыслу». -Почему семантика на третьем месте, а не на первом: замер на `nomic-embed-text` -показал, что на русском он почти не разделяет смыслы — «велосипедная камера» -давала мультиметру 0.689, а верное попадание на «паяльник» — 0.691. Порогом -такое не разделить, поэтому лучше честное «не нашёл», чем уверенно показанное -не то. Хочется настоящего смыслового поиска — нужен многоязычный эмбеддер: +Эмбеддер — **`bge-m3`**, многоязычный. Прежний `nomic-embed-text` на русском +смыслы почти не разделял: «велосипедная камера» набирала мультиметру 0.704 — +больше верного ответа на «паяльник» (0.691). Порогом такое не разделить. +С `bge-m3` разделение появилось: мусор ушёл ниже 0.46, верные держатся выше 0.53. + +Но и с ним семантика остаётся догадкой, а не основой. Замер на настоящей базе +из шести десятков вещей честнее лабораторного: абстрактное «чем посветить» +набирало лампам всего 0.411, а посторонняя «детская коляска» — 0.535. Полностью +они не разделяются, поэтому `SEMANTIC_MIN` в `search.py` выставлен так, чтобы +отсечь весь мусор ценой слабых догадок: помощник, уверенно показывающий не то, +хуже промолчавшего. Точных запросов это не касается — они находятся по словам +и тегам. + +**При смене эмбеддера** поменять `EMBED_MODEL` в `config.py`, перемерить порог +(у каждой модели своя шкала) и обязательно пересчитать векторы: ``` -ollama pull bge-m3 +venv\Scripts\python.exe tools\reindex.py ``` -затем в `hlamingo\config.py` сменить `EMBED_MODEL`, обнулить `EMBED_DOC_PREFIX` -и `EMBED_QUERY_PREFIX` и **один раз пересчитать векторы** — `search.reindex(db)`. -Пересчёт обязателен: векторы разных моделей несовместимы, и смешанная база -тихо испортит поиск, ничем себя не выдав. +Пересчёт не опция: векторы разных моделей несовместимы, и смешанная база тихо +испортит поиск, ничем себя не выдав. Скрипт сам делает копию базы перед работой. ## Что если Ollama выключен diff --git a/hlamingo/config.py b/hlamingo/config.py index 2a61d1e..a8c172b 100644 --- a/hlamingo/config.py +++ b/hlamingo/config.py @@ -146,17 +146,20 @@ OLLAMA_HOST = (os.environ.get("OLLAMA_HOST") or "http://localhost:11434").rstrip CHAT_MODEL = os.environ.get("HLAMINGO_CHAT_MODEL") or "qwen2.5:7b" CHAT_MODEL_ALT = "carstenuhlig/omnicoder-9b" -# Единственный эмбеддер на сервере. С русским справляется слабо (замеры — в -# комментарии к search.py), поэтому смысловой поиск у нас вспомогательный. -# Станет заметно лучше с многоязычной моделью: -# ollama pull bge-m3 -# после чего сменить строку ниже, обнулить префиксы и один раз пересчитать -# векторы: search.reindex(db) — старые векторы с новой моделью несовместимы. -EMBED_MODEL = os.environ.get("HLAMINGO_EMBED_MODEL") or "nomic-embed-text:latest" -# nomic-embed-text обучен на префиксах задачи: без них качество ещё ниже. -# Документы и запросы кодируются РАЗНЫМИ префиксами. Для bge-m3 префиксы не нужны. -EMBED_DOC_PREFIX = "search_document: " -EMBED_QUERY_PREFIX = "search_query: " +# bge-m3 — многоязычная модель, на русском заметно лучше прежней +# nomic-embed-text. Замер на одном и том же складе: +# nomic-embed-text: «велосипедная камера» набирала мультиметру 0.704, а верный +# ответ на «паяльник» — 0.691. Зазор −0.013, порогом не +# разделить: мусор всегда обгонял правильное. +# bge-m3: верные не ниже 0.537, весь мусор не выше 0.460. +# Зазор +0.077, порог 0.50 работает. +# При смене модели нужен полный пересчёт: search.reindex(db). Векторы разных +# моделей несовместимы, и смешанная база тихо испортит поиск. +EMBED_MODEL = os.environ.get("HLAMINGO_EMBED_MODEL") or "bge-m3" +# bge-m3 префиксов задачи не требует — в отличие от nomic-embed-text, которому +# были нужны разные приставки для документа и запроса. +EMBED_DOC_PREFIX = "" +EMBED_QUERY_PREFIX = "" CHAT_TIMEOUT = 120 # разбор одной фразы; больше не нужно EMBED_TIMEOUT = 30 diff --git a/hlamingo/search.py b/hlamingo/search.py index a75e11e..d357be6 100644 --- a/hlamingo/search.py +++ b/hlamingo/search.py @@ -7,14 +7,16 @@ совпавших. Работает всегда, в том числе с выключенным Ollama. 2. СИНОНИМЫ от модели («изолента» → «изоляционная лента», «ПВХ-лента») лежат в самой карточке и расширяют словарь предмета — то есть усиливают пункт 1. -3. СЕМАНТИКА — дополнение, а не основа. Замер на nomic-embed-text показал, что - на русском он почти не разделяет смыслы: «велосипедная камера» давала - мультиметру 0.689, а верный ответ на «паяльник» — 0.691. Порогом такое не - разделить, поэтому семантика подключается только когда по словам ничего не - нашлось, с высоким порогом, и помечается в выдаче как догадка. +3. СЕМАНТИКА — дополнение, а не основа. Подключается, только когда по словам + ничего не нашлось, и помечается в выдаче как догадка. -Хочется настоящего смыслового поиска — нужен многоязычный эмбеддер (bge-m3): -см. EMBED_MODEL в config.py и раздел про поиск в README. + Раньше от неё было мало толку: nomic-embed-text на русском почти не + разделял смыслы — «велосипедная камера» набирала мультиметру больше, чем + верный ответ на «паяльник». С bge-m3 разделение появилось (верные выше + 0.53, посторонние ниже 0.46), и догадки стали осмысленными. + + Основой она всё равно не становится: слова и теги дают точный ответ там, + где человек помнит название или маркировку, а это обычный случай. """ import re @@ -46,7 +48,17 @@ _ENDINGS = ( _WORD_RE = re.compile(r"[0-9a-zA-Zа-яёА-ЯЁ]+") MIN_WORD_SCORE = 0.5 # совпало меньше половины значимых слов — не показываем -SEMANTIC_MIN = 0.70 # порог догадки по смыслу (см. калибровку выше) + +# Порог смысловой догадки. Откалиброван на bge-m3 и НА РЕАЛЬНОЙ базе, а не на +# придуманном наборе: там картина честнее и хуже. Посторонние запросы доходили +# до 0.535 («детская коляска» → «осколок люстры из детской»), а слабые верные +# догадки начинались с 0.41 — то есть полностью они не разделяются. +# +# Выбран порог, отсекающий весь мусор, ценой потери слабых догадок: помощник, +# уверенно показывающий не то, хуже промолчавшего. Точные запросы это не +# затрагивает — они находятся по словам и тегам, а не смыслом. +# При смене эмбеддера порог надо перемерить: у каждой модели своя шкала. +SEMANTIC_MIN = 0.56 def stem(word): diff --git a/tools/reindex.py b/tools/reindex.py new file mode 100644 index 0000000..829673e --- /dev/null +++ b/tools/reindex.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- +"""Пересчёт векторов после смены модели эмбеддера. + +Векторы разных моделей несовместимы: если половина базы посчитана одной +моделью, а половина другой, косинус между ними — шум, и поиск тихо портится, +ничем себя не выдавая. Поэтому при смене EMBED_MODEL нужен полный пересчёт. + +Запуск (сервер лучше остановить, чтобы не писал в базу одновременно): + venv\\Scripts\\python.exe tools\\reindex.py +""" + +import io +import os +import sys +import time +import shutil + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +try: + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace") +except Exception: + pass + +from hlamingo import store, search, ollama, config # noqa: E402 + + +def main(): + if not config.DB_FILE.exists(): + print("База пуста — пересчитывать нечего.") + return 0 + if not ollama.check(): + print("Ollama недоступна (%s) — без неё векторы не посчитать." % config.OLLAMA_HOST) + return 1 + + db = store.load() + total = len(db["items"]) + print("Модель эмбеддера: %s" % config.EMBED_MODEL) + print("Вещей в базе: %d" % total) + if not total: + return 0 + + # Своя копия рядом с автоматическими: пересчёт трогает каждую запись. + backup = config.DATA_DIR / "backups" / ("db-before-reindex-%s.json" + % time.strftime("%Y%m%d-%H%M%S")) + backup.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(str(config.DB_FILE), str(backup)) + print("Копия: %s\n" % backup.name) + + t0 = time.time() + done = 0 + for item in db["items"].values(): + item["emb"] = None + for item in db["items"].values(): + vector = ollama.embed(search.item_text(item)) + if not vector: + print(" не удалось посчитать: %s" % item.get("name")) + continue + item["emb"] = vector + done += 1 + print(" [%d/%d] %s" % (done, total, item.get("name"))) + + store.save(db) + print("\nГотово: %d из %d за %.1f с." % (done, total, time.time() - t0)) + if done < total: + print("Часть векторов не посчиталась — запусти ещё раз, когда Ollama ответит.") + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main())