Эмбеддер 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:
+2
-2
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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):
|
||||||
|
|||||||
@@ -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())
|
||||||
Reference in New Issue
Block a user