Эмбеддер bge-m3: смысловой поиск на русском наконец разделяет смыслы

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 18:23:22 +03:00
co-authored by Claude Opus 5
parent 781690365e
commit 39c807d4de
5 changed files with 127 additions and 33 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ OLLAMA_HOST=http://localhost:11434
# Модель для эмбеддингов (поиск по смыслу). Необязательна — без неё # Модель для эмбеддингов (поиск по смыслу). Необязательна — без неё
# работает поиск по словам и тегам: # работает поиск по словам и тегам:
# ollama pull nomic-embed-text # ollama pull bge-m3
# HLAMINGO_EMBED_MODEL=nomic-embed-text:latest # HLAMINGO_EMBED_MODEL=bge-m3
# Порт веб-интерфейса. # Порт веб-интерфейса.
# HLAMINGO_PORT=5000 # HLAMINGO_PORT=5000
+20 -12
View File
@@ -17,7 +17,7 @@
``` ```
ollama pull qwen2.5:7b разбор фраз и теги 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 | | `OLLAMA_HOST` | `http://localhost:11434` | адрес Ollama |
| `HLAMINGO_CHAT_MODEL` | `qwen2.5:7b` | разбор фраз и теги | | `HLAMINGO_CHAT_MODEL` | `qwen2.5:7b` | разбор фраз и теги |
| `HLAMINGO_EMBED_MODEL` | `nomic-embed-text:latest` | поиск по смыслу | | `HLAMINGO_EMBED_MODEL` | `bge-m3` | поиск по смыслу |
| `HLAMINGO_PORT` | `5000` | порт веб-интерфейса | | `HLAMINGO_PORT` | `5000` | порт веб-интерфейса |
| `HLAMINGO_DATA_DIR` | — | другая папка для данных и `.env`; используется тестами | | `HLAMINGO_DATA_DIR` | — | другая папка для данных и `.env`; используется тестами |
@@ -168,20 +168,28 @@ Photo Picker, в котором опции «Камера» нет вообще
3. **По смыслу (эмбеддинги)** — только если по словам ничего не нашлось, 3. **По смыслу (эмбеддинги)** — только если по словам ничего не нашлось,
с высоким порогом и с пометкой «похоже по смыслу». с высоким порогом и с пометкой «похоже по смыслу».
Почему семантика на третьем месте, а не на первом: замер на `nomic-embed-text` Эмбеддер — **`bge-m3`**, многоязычный. Прежний `nomic-embed-text` на русском
показал, что на русском он почти не разделяет смыслы — «велосипедная камера» смыслы почти не разделял: «велосипедная камера» набирала мультиметру 0.704 —
давала мультиметру 0.689, а верное попадание на «паяльник» 0.691. Порогом больше верного ответа на «паяльник» (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 выключен ## Что если Ollama выключен
+14 -11
View File
@@ -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 = os.environ.get("HLAMINGO_CHAT_MODEL") or "qwen2.5:7b"
CHAT_MODEL_ALT = "carstenuhlig/omnicoder-9b" CHAT_MODEL_ALT = "carstenuhlig/omnicoder-9b"
# Единственный эмбеддер на сервере. С русским справляется слабо (замеры — в # bge-m3 — многоязычная модель, на русском заметно лучше прежней
# комментарии к search.py), поэтому смысловой поиск у нас вспомогательный. # nomic-embed-text. Замер на одном и том же складе:
# Станет заметно лучше с многоязычной моделью: # nomic-embed-text: «велосипедная камера» набирала мультиметру 0.704, а верный
# ollama pull bge-m3 # ответ на «паяльник» — 0.691. Зазор −0.013, порогом не
# после чего сменить строку ниже, обнулить префиксы и один раз пересчитать # разделить: мусор всегда обгонял правильное.
# векторы: search.reindex(db) — старые векторы с новой моделью несовместимы. # bge-m3: верные не ниже 0.537, весь мусор не выше 0.460.
EMBED_MODEL = os.environ.get("HLAMINGO_EMBED_MODEL") or "nomic-embed-text:latest" # Зазор +0.077, порог 0.50 работает.
# nomic-embed-text обучен на префиксах задачи: без них качество ещё ниже. # При смене модели нужен полный пересчёт: search.reindex(db). Векторы разных
# Документы и запросы кодируются РАЗНЫМИ префиксами. Для bge-m3 префиксы не нужны. # моделей несовместимы, и смешанная база тихо испортит поиск.
EMBED_DOC_PREFIX = "search_document: " EMBED_MODEL = os.environ.get("HLAMINGO_EMBED_MODEL") or "bge-m3"
EMBED_QUERY_PREFIX = "search_query: " # bge-m3 префиксов задачи не требует — в отличие от nomic-embed-text, которому
# были нужны разные приставки для документа и запроса.
EMBED_DOC_PREFIX = ""
EMBED_QUERY_PREFIX = ""
CHAT_TIMEOUT = 120 # разбор одной фразы; больше не нужно CHAT_TIMEOUT = 120 # разбор одной фразы; больше не нужно
EMBED_TIMEOUT = 30 EMBED_TIMEOUT = 30
+20 -8
View File
@@ -7,14 +7,16 @@
совпавших. Работает всегда, в том числе с выключенным Ollama. совпавших. Работает всегда, в том числе с выключенным Ollama.
2. СИНОНИМЫ от модели («изолента» → «изоляционная лента», «ПВХ-лента») лежат 2. СИНОНИМЫ от модели («изолента» → «изоляционная лента», «ПВХ-лента») лежат
в самой карточке и расширяют словарь предмета — то есть усиливают пункт 1. в самой карточке и расширяют словарь предмета — то есть усиливают пункт 1.
3. СЕМАНТИКА — дополнение, а не основа. Замер на nomic-embed-text показал, что 3. СЕМАНТИКА — дополнение, а не основа. Подключается, только когда по словам
на русском он почти не разделяет смыслы: «велосипедная камера» давала ничего не нашлось, и помечается в выдаче как догадка.
мультиметру 0.689, а верный ответ на «паяльник» — 0.691. Порогом такое не
разделить, поэтому семантика подключается только когда по словам ничего не
нашлось, с высоким порогом, и помечается в выдаче как догадка.
Хочется настоящего смыслового поиска — нужен многоязычный эмбеддер (bge-m3): Раньше от неё было мало толку: nomic-embed-text на русском почти не
см. EMBED_MODEL в config.py и раздел про поиск в README. разделял смыслы — «велосипедная камера» набирала мультиметру больше, чем
верный ответ на «паяльник». С bge-m3 разделение появилось (верные выше
0.53, посторонние ниже 0.46), и догадки стали осмысленными.
Основой она всё равно не становится: слова и теги дают точный ответ там,
где человек помнит название или маркировку, а это обычный случай.
""" """
import re import re
@@ -46,7 +48,17 @@ _ENDINGS = (
_WORD_RE = re.compile(r"[0-9a-zA-Zа-яёА-ЯЁ]+") _WORD_RE = re.compile(r"[0-9a-zA-Zа-яёА-ЯЁ]+")
MIN_WORD_SCORE = 0.5 # совпало меньше половины значимых слов — не показываем MIN_WORD_SCORE = 0.5 # совпало меньше половины значимых слов — не показываем
SEMANTIC_MIN = 0.70 # порог догадки по смыслу (см. калибровку выше)
# Порог смысловой догадки. Откалиброван на bge-m3 и НА РЕАЛЬНОЙ базе, а не на
# придуманном наборе: там картина честнее и хуже. Посторонние запросы доходили
# до 0.535 («детская коляска» → «осколок люстры из детской»), а слабые верные
# догадки начинались с 0.41 — то есть полностью они не разделяются.
#
# Выбран порог, отсекающий весь мусор, ценой потери слабых догадок: помощник,
# уверенно показывающий не то, хуже промолчавшего. Точные запросы это не
# затрагивает — они находятся по словам и тегам, а не смыслом.
# При смене эмбеддера порог надо перемерить: у каждой модели своя шкала.
SEMANTIC_MIN = 0.56
def stem(word): def stem(word):
+71
View File
@@ -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())