Files
wiki/tasks/snowbike-rag/scripts/search.py
2026-04-12 21:55:33 +03:00

437 lines
16 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.
#!/usr/bin/env python3
"""
Гибридный поиск: Meilisearch + ChromaDB + LLM суммаризация.
"""
import json
import os
import re
import sys
import time
from pathlib import Path
from typing import Optional
import requests as _requests
# Сессия без прокси для внутренних сервисов (Meilisearch/ChromaDB на localhost)
_internal_session = _requests.Session()
_internal_session.trust_env = False
# Загружаем .env
def load_env():
env_file = Path.home() / ".openclaw" / ".env"
if env_file.exists():
with open(env_file) as f:
for line in f:
line = line.strip()
if line and not line.startswith("#") and "=" in line:
key, _, val = line.partition("=")
os.environ.setdefault(key.strip(), val.strip())
load_env()
MEILI_URL = "http://127.0.0.1:7700"
CHROMA_PATH = str(Path(__file__).parent.parent / "data" / "chromadb")
COLLECTION_NAME = "snowbike_embeddings"
INDEX_NAME = "snowbike_messages"
MODEL_NAME = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
OPENROUTER_KEY = os.environ.get("OPENROUTER_API_KEY", "")
LLM_MODEL = "anthropic/claude-sonnet-4-5"
# Порог релевантности для ChromaDB (меньше = лучше совпадение)
CHROMA_DISTANCE_THRESHOLD = 1.2
# === СЕЗОННЫЙ ФИЛЬТР ===
# Маппинг ключевых слов → месяцы (включая соседние для контекста)
SEASON_KEYWORDS = {
# Месяцы
"январ": [12, 1, 2],
"феврал": [1, 2, 3],
"март": [2, 3, 4],
"апрел": [3, 4, 5],
"май": [4, 5, 6],
"июн": [5, 6, 7],
"июл": [6, 7, 8],
"август": [7, 8, 9],
"сентябр": [8, 9, 10],
"октябр": [9, 10, 11],
"ноябр": [10, 11, 12],
"декабр": [11, 12, 1],
# Сезоны
"зим": [12, 1, 2],
"весн": [3, 4, 5],
"лет": [6, 7, 8],
"осен": [9, 10, 11],
# Сезон катания
"сезон": [11, 12, 1, 2, 3, 4], # типичный сноубайк-сезон
"межсезон": [5, 6, 7, 8, 9, 10],
}
def detect_season_months(query: str) -> Optional[list]:
"""
Определяет месяцы из запроса.
Возвращает список номеров месяцев (1-12) или None.
"""
query_lower = query.lower()
for keyword, months in SEASON_KEYWORDS.items():
if keyword in query_lower:
return months
return None
SYSTEM_PROMPT = """Ты — помощник по сноубайкам. Отвечаешь на вопросы на основе сообщений из Telegram-группы «Сноубайк Россия».
Правила:
1. Отвечай только на русском языке
2. Используй только информацию из предоставленных сообщений
3. Если информации недостаточно — честно скажи об этом, перечисли что было упомянуто
4. Всегда указывай конкретику: ДАТЫ (в формате «в апреле 2024», «в январе 2023» и т.д.), имена (если есть), цифры
5. Агрегируй мнения: если несколько человек говорят одно и то же — обобщи
6. Будь конкретен: перечисли проблемы/решения по пунктам
7. При ответе на сезонные вопросы — обязательно указывай из каких месяцев/лет взяты данные"""
USER_PROMPT_TEMPLATE = """Вопрос: {question}
Найденные сообщения из чата «Сноубайк Россия»:
{context}
Дай связный ответ на вопрос. Если нашлись конкретные факты — перечисли их.
Обязательно упоминай даты сообщений (месяц и год) когда это важно для контекста."""
# Глобальные клиенты (инициализируются лениво)
_chroma_collection = None
_embed_model = None
def get_chroma_collection():
global _chroma_collection
if _chroma_collection is None:
import chromadb
client = chromadb.PersistentClient(path=CHROMA_PATH)
_chroma_collection = client.get_collection(COLLECTION_NAME)
return _chroma_collection
def get_embed_model():
global _embed_model
if _embed_model is None:
from sentence_transformers import SentenceTransformer
_embed_model = SentenceTransformer(MODEL_NAME)
return _embed_model
def search_meilisearch(query: str, topic_ids: Optional[list] = None, limit: int = 20,
season_months: Optional[list] = None) -> list:
"""Полнотекстовый поиск в Meilisearch через HTTP."""
try:
fetch_limit = limit * 6 if season_months else limit
params = {"q": query, "limit": fetch_limit}
if topic_ids:
filter_str = " OR ".join(f"topic_id = {t}" for t in topic_ids)
params["filter"] = filter_str
resp = _internal_session.post(
f"{MEILI_URL}/indexes/{INDEX_NAME}/search",
json=params,
timeout=10,
)
resp.raise_for_status()
hits = resp.json().get("hits", [])
if season_months:
# Разделяем на "в сезон" и "вне сезона", возвращаем сначала сезонные
in_season = []
out_season = []
for hit in hits:
date_str = hit.get("date", "")
try:
month = int(date_str[5:7]) if len(date_str) >= 7 else 0
except (ValueError, IndexError):
month = 0
if month in season_months:
in_season.append(hit)
else:
out_season.append(hit)
hits = (in_season + out_season)[:limit]
return hits[:limit]
except Exception as e:
print(f"Meilisearch ошибка: {e}", file=sys.stderr)
return []
def search_chromadb(query: str, topic_ids: Optional[list] = None, limit: int = 20,
season_months: Optional[list] = None) -> list:
"""Семантический поиск в ChromaDB с фильтрацией по дистанции."""
try:
model = get_embed_model()
collection = get_chroma_collection()
embedding = model.encode(query).tolist()
# Строим where-фильтр
conditions = []
if topic_ids:
if len(topic_ids) == 1:
conditions.append({"topic_id": topic_ids[0]})
else:
conditions.append({"$or": [{"topic_id": t} for t in topic_ids]})
if season_months:
# Используем поле month если доступно (после переиндексации)
# Для старых записей без month — пробуем с запасом и пост-фильтрацией
has_month_field = _check_month_field(collection)
if has_month_field:
if len(season_months) == 1:
conditions.append({"month": season_months[0]})
else:
conditions.append({"$or": [{"month": m} for m in season_months]})
where = None
if len(conditions) == 1:
where = conditions[0]
elif len(conditions) > 1:
where = {"$and": conditions}
fetch_n = limit if not (season_months and not _check_month_field(collection)) else limit * 4
kwargs = {
"query_embeddings": [embedding],
"n_results": min(fetch_n, collection.count()),
"include": ["documents", "metadatas", "distances"],
}
if where:
kwargs["where"] = where
result = collection.query(**kwargs)
hits = []
ids = result.get("ids", [[]])[0]
docs = result.get("documents", [[]])[0]
metas = result.get("metadatas", [[]])[0]
dists = result.get("distances", [[]])[0]
for msg_id, doc, meta, dist in zip(ids, docs, metas, dists):
if dist > CHROMA_DISTANCE_THRESHOLD:
continue
# Пост-фильтр по месяцу если нет поля month
if season_months and not _check_month_field(collection):
date_str = meta.get("date", "")
try:
month = int(date_str[5:7]) if len(date_str) >= 7 else 0
except (ValueError, IndexError):
month = 0
if month not in season_months:
continue
hits.append({
"id": int(msg_id),
"text": doc,
"topic_id": meta.get("topic_id"),
"topic_title": meta.get("topic_title", ""),
"date": meta.get("date", ""),
"from_id": meta.get("from_id", ""),
"_chroma_distance": dist,
})
return hits[:limit]
except Exception as e:
print(f"ChromaDB ошибка: {e}", file=sys.stderr)
return []
_month_field_cache = None
def _check_month_field(collection) -> bool:
"""Проверяем есть ли поле month в коллекции (кэшируем результат)."""
global _month_field_cache
if _month_field_cache is not None:
return _month_field_cache
try:
sample = collection.get(limit=1, include=["metadatas"])
metas = sample.get("metadatas", [])
if metas and "month" in metas[0]:
_month_field_cache = True
else:
_month_field_cache = False
except Exception:
_month_field_cache = False
return _month_field_cache
def merge_results(meili_hits: list, chroma_hits: list, limit: int = 15) -> list:
"""Объединяем и дедуплицируем результаты."""
seen_ids = set()
merged = []
# Meilisearch результаты — полнотекстовые, высокий приоритет
for hit in meili_hits:
msg_id = hit.get("id")
if msg_id and msg_id not in seen_ids:
hit["_source"] = "meilisearch"
merged.append(hit)
seen_ids.add(msg_id)
# ChromaDB результаты — семантические, дополняем
for hit in chroma_hits:
msg_id = hit.get("id")
if msg_id and msg_id not in seen_ids:
hit["_source"] = "chromadb"
merged.append(hit)
seen_ids.add(msg_id)
return merged[:limit]
def format_context(messages: list) -> str:
"""Форматируем контекст для LLM с явными датами."""
lines = []
for i, msg in enumerate(messages, 1):
date_full = msg.get("date", "")
# Форматируем дату как "15 апреля 2024"
date_display = _format_date_russian(date_full)
topic = msg.get("topic_title", "?")
author = msg.get("from_id", "?")
text = msg.get("text", "")
msg_id = msg.get("id", "?")
source = msg.get("_source", "")
lines.append(f"[{i}] ID:{msg_id} | Дата: {date_display} | Топик: {topic} | автор:{author} | источник:{source}")
lines.append(f" {text}")
lines.append("")
return "\n".join(lines)
MONTH_NAMES_RU = {
1: "января", 2: "февраля", 3: "марта", 4: "апреля",
5: "мая", 6: "июня", 7: "июля", 8: "августа",
9: "сентября", 10: "октября", 11: "ноября", 12: "декабря",
}
def _format_date_russian(date_str: str) -> str:
"""Преобразует ISO дату в читаемый русский формат."""
try:
# "2024-04-15T..."
year = int(date_str[:4])
month = int(date_str[5:7])
day = int(date_str[8:10])
return f"{day} {MONTH_NAMES_RU[month]} {year}"
except Exception:
return date_str[:10] if date_str else "?"
def ask_llm(question: str, context: str) -> str:
"""Задаём вопрос LLM через OpenRouter."""
if not OPENROUTER_KEY:
return "ОШИБКА: OPENROUTER_API_KEY не задан в ~/.openclaw/.env"
user_msg = USER_PROMPT_TEMPLATE.format(question=question, context=context)
response = _requests.post(
"https://openrouter.ai/api/v1/chat/completions",
headers={
"Authorization": f"Bearer {OPENROUTER_KEY}",
"Content-Type": "application/json",
"HTTP-Referer": "https://snowbike-rag.local",
"X-Title": "Snowbike RAG",
},
json={
"model": LLM_MODEL,
"messages": [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_msg},
],
"max_tokens": 1500,
"temperature": 0.3,
},
timeout=30,
)
response.raise_for_status()
data = response.json()
return data["choices"][0]["message"]["content"]
def search(query: str, topic_ids: Optional[list] = None, limit: int = 15) -> dict:
"""
Главная функция гибридного поиска.
Returns:
dict с полями: query, answer, sources, count, time_ms
"""
start_time = time.time()
# Определяем сезонные месяцы
season_months = detect_season_months(query)
if season_months:
print(f"Сезонный запрос, фильтр по месяцам: {season_months}", file=sys.stderr)
# Поиск
meili_hits = search_meilisearch(query, topic_ids, limit=20, season_months=season_months)
chroma_hits = search_chromadb(query, topic_ids, limit=20, season_months=season_months)
print(f"Meilisearch hits: {len(meili_hits)}, ChromaDB hits: {len(chroma_hits)}", file=sys.stderr)
merged = merge_results(meili_hits, chroma_hits, limit=limit)
if not merged:
return {
"query": query,
"answer": "К сожалению, по вашему запросу ничего не найдено в базе сообщений.",
"sources": [],
"count": 0,
"time_ms": int((time.time() - start_time) * 1000),
}
# Формируем контекст для LLM
context = format_context(merged)
# LLM ответ
answer = ask_llm(query, context)
# Источники
sources = [
{
"id": msg.get("id"),
"date": msg.get("date", "")[:10],
"date_display": _format_date_russian(msg.get("date", "")),
"topic": msg.get("topic_title", ""),
"author": str(msg.get("from_id", "")),
"text_preview": msg.get("text", "")[:120],
}
for msg in merged
]
elapsed_ms = int((time.time() - start_time) * 1000)
return {
"query": query,
"answer": answer,
"sources": sources,
"count": len(merged),
"time_ms": elapsed_ms,
"season_months": season_months,
}
if __name__ == "__main__":
query = sys.argv[1] if len(sys.argv) > 1 else "проблемы ремень трикстер"
print(f"Запрос: {query}\n")
result = search(query)
print(f"Найдено источников: {result['count']}")
print(f"Время: {result['time_ms']} мс")
if result.get("season_months"):
print(f"Сезонный фильтр (месяцы): {result['season_months']}")
print("\n=== ОТВЕТ ===")
print(result["answer"])
print("\n=== ИСТОЧНИКИ ===")
for s in result["sources"][:5]:
print(f" [{s['id']}] {s['date_display']} | {s['topic']} | {s['text_preview'][:100]}...")