Files
hlamingo/hlamingo/search.py
T
alexandClaude Opus 5 17b7d49d2f hlamingo — помощник «где что лежит»
Веб-помощник для домашнего склада: помнит, в какой коробке и в каком месте
лежит вещь, и находит её по названию, тегам и маркировке. Открывается
с телефона, интеллект — локальная Ollama.

Основное:
- структурный ввод «место → ёмкость → предмет» и кнопки
  Положить / Забрать / Переместить / Удалить;
- иерархия мест (Гараж → Стеллаж А → Полка 2), ёмкости с кодом «буква+цифра»,
  состояние «на руках» для взятых вещей;
- поиск по словам с учётом русских окончаний, теги от модели расширяют
  словарь предмета, смысловой поиск — вспомогательный (замеры в search.py);
- фото к вещам и коробкам: съёмка и выбор из галереи;
- правка и удаление всех сущностей, удаление коробки не теряет её содержимое;
- работает и без Ollama: отключаются только разбор фраз, теги и поиск
  по смыслу.

Данные (data/, static/photos/) и локальный конфиг (.env) в репозиторий
не попадают. Тесты работают только в песочнице: путь к данным берётся
из окружения, tests/guard.py отказывается запускаться на живых данных.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 14:27:20 +03:00

175 lines
8.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Поиск предметов: по словам (с учётом русских окончаний) плюс семантика.
Порядок сигналов выбран по замерам на реальном эмбеддере, а не наугад.
1. СЛОВА — основной сигнал. Запрос и карточка предмета режутся на слова, слова
грубо приводятся к основе («стабилизаторы» → «стабилизатор»), считается доля
совпавших. Работает всегда, в том числе с выключенным Ollama.
2. СИНОНИМЫ от модели («изолента» → «изоляционная лента», «ПВХ-лента») лежат
в самой карточке и расширяют словарь предмета — то есть усиливают пункт 1.
3. СЕМАНТИКА — дополнение, а не основа. Замер на nomic-embed-text показал, что
на русском он почти не разделяет смыслы: «велосипедная камера» давала
мультиметру 0.689, а верный ответ на «паяльник» — 0.691. Порогом такое не
разделить, поэтому семантика подключается только когда по словам ничего не
нашлось, с высоким порогом, и помечается в выдаче как догадка.
Хочется настоящего смыслового поиска — нужен многоязычный эмбеддер (bge-m3):
см. EMBED_MODEL в config.py и раздел про поиск в README.
"""
import re
import math
import logging
from . import ollama
from .config import TOP_K, MIN_SCORE
logger = logging.getLogger(__name__)
# Слова, которые есть в любом запросе и ничего не сообщают о предмете.
STOP = {
"где", "что", "чем", "как", "куда", "мой", "моя", "моё", "мои", "мне",
"в", "во", "на", "за", "под", "из", "от", "до", "по", "с", "со", "у", "к",
"и", "а", "но", "или", "для", "же", "ли", "это", "тот", "та", "те",
"лежит", "лежат", "находится", "хранится", "положил", "искать", "найти",
}
# Самые частые русские окончания, от длинных к коротким: обрезаем первое
# подходящее. Это не морфология, а дешёвая замена ей — но «стабилизаторы»,
# «стабилизатора» и «стабилизатором» после неё сходятся в одну основу.
_ENDINGS = (
"ами", "ями", "ого", "ему", "ому", "ыми", "ими", "ах", "ях", "ов", "ев",
"ам", "ям", "ой", "ей", "ые", "ий", "ый", "ая", "яя", "ое", "ее", "ии",
"ом", "ем", "им", "ых", "их", "ую", "юю", "ть",
"а", "я", "ы", "и", "о", "е", "у", "ю", "й", "ь",
)
_WORD_RE = re.compile(r"[0-9a-zA-Zа-яёА-ЯЁ]+")
MIN_WORD_SCORE = 0.5 # совпало меньше половины значимых слов — не показываем
SEMANTIC_MIN = 0.70 # порог догадки по смыслу (см. калибровку выше)
def stem(word):
"""Грубая основа слова. Короткие слова и коды («A3», «7805») не трогаем."""
word = word.lower().replace("ё", "е")
if len(word) <= 4 or word.isdigit():
return word
for ending in _ENDINGS:
if word.endswith(ending) and len(word) - len(ending) >= 4:
return word[:-len(ending)]
return word
def tokens(text, keep_stop=False):
"""Значимые основы слов текста."""
out = []
for word in _WORD_RE.findall(text or ""):
low = word.lower()
if not keep_stop and low in STOP:
continue
out.append(stem(word))
return out
def item_text(item):
"""Всё, по чему предмет можно узнать: название, синонимы, заметка."""
parts = [item.get("name") or ""]
parts += item.get("aliases") or []
if item.get("note"):
parts.append(item["note"])
return ", ".join(p for p in parts if p)
def word_score(query, item):
"""Доля слов запроса, найденных в карточке предмета: 0…1."""
q = tokens(query)
if not q:
return 0.0
haystack = set(tokens(item_text(item)))
if not haystack:
return 0.0
found = 0
for word in q:
if word in haystack:
found += 1
continue
# Частичное совпадение: «стабилиз» ↔ «стабилизатор». Помогает там, где
# обрезка окончаний сработала по-разному в запросе и в карточке.
if len(word) >= 5 and any(h.startswith(word) or word.startswith(h)
for h in haystack if len(h) >= 5):
found += 0.8
return min(1.0, found / len(q))
# ============================================================
# Семантика
# ============================================================
def cosine(a, b):
if not a or not b or len(a) != len(b):
return 0.0
dot = sum(x * y for x, y in zip(a, b))
na = math.sqrt(sum(x * x for x in a))
nb = math.sqrt(sum(y * y for y in b))
if na == 0 or nb == 0:
return 0.0
return dot / (na * nb)
def ensure_embeddings(db):
"""Досчитывает векторы предметам без них. True, если базу надо сохранить."""
changed = False
for item in db["items"].values():
if item.get("emb"):
continue
vector = ollama.embed(item_text(item))
if not vector:
return changed # эмбеддер молчит — не долбим его на каждой записи
item["emb"] = vector
changed = True
return changed
def reindex(db):
"""Полный пересчёт векторов — после смены модели эмбеддера.
Векторы разных моделей несовместимы: смешанная база тихо испортит поиск,
поэтому при смене EMBED_MODEL пересчитывать нужно всё сразу.
"""
for item in db["items"].values():
item["emb"] = None
return ensure_embeddings(db)
# ============================================================
# Поиск
# ============================================================
def search(db, query, top_k=TOP_K, min_score=MIN_SCORE):
"""Список (item, score, why). why — «по названию» или «похоже по смыслу»."""
if not (query or "").strip() or not db["items"]:
return []
scored = []
for item in db["items"].values():
score = word_score(query, item)
if score >= MIN_WORD_SCORE:
scored.append((item, score, "по названию"))
scored.sort(key=lambda t: t[1], reverse=True)
if scored:
return scored[:top_k]
# По словам не нашлось — пробуем догадаться по смыслу. Отдельно и с высоким
# порогом: лучше честное «не нашёл», чем уверенно показанное не то.
vector = ollama.embed(query, is_query=True)
if not vector:
return []
guesses = []
for item in db["items"].values():
if not item.get("emb"):
continue
score = cosine(vector, item["emb"])
if score >= max(SEMANTIC_MIN, min_score):
guesses.append((item, score, "похоже по смыслу"))
guesses.sort(key=lambda t: t[1], reverse=True)
return guesses[:top_k]