import os import pickle from pathlib import Path from typing import List import faiss import numpy as np import streamlit as st from langchain_core.embeddings import Embeddings from openai import OpenAI from sentence_transformers import SentenceTransformer, CrossEncoder LM_STUDIO_URL = os.getenv("LM_STUDIO_URL", "http://localhost:1234") EMBED_MODEL = "intfloat/multilingual-e5-large" VECTOR_DIR = Path(__file__).parent / "vector_store" VECTOR_INDEX = VECTOR_DIR / "index.faiss.bin" VECTOR_META = VECTOR_DIR / "metadata.pkl" SYSTEM_PROMPT = """Ты — специалист техподдержки. Отвечай клиенту, используя ТОЛЬКО информацию из переданных тикетов (контекст). Правила: - Если контекст содержит подходящее решение — напиши ответ своими словами, адаптируя под вопрос - Если контекст не относится к вопросу — напиши: «Недостаточно информации в истории обращений» - НЕ придумывай ответы, НЕ используй общие знания - НЕ говори «обратитесь в службу поддержки» — ты сам и есть поддержка - Укажи в конце: «Основано на тикете №...»""" FETCH_K = 20 # сколько достаём из FAISS для реранжа FINAL_K = 5 # сколько оставляем после реранжа class LocalEmbeddings(Embeddings): def __init__(self, model_name: str): self.model = SentenceTransformer(model_name, device="cuda") def embed_documents(self, texts: List[str]) -> List[List[float]]: emb = self.model.encode(texts, normalize_embeddings=True) return emb.tolist() def embed_query(self, text: str) -> List[float]: emb = self.model.encode([text], normalize_embeddings=True) return emb[0].tolist() @st.cache_resource def load_embeddings(): return LocalEmbeddings(EMBED_MODEL) @st.cache_resource def load_reranker(): return CrossEncoder( "cross-encoder/mmarco-mMiniLMv2-L12-H384-v1", max_length=512, device="cpu", ) def load_faiss_index(path: Path): return faiss.deserialize_index(np.frombuffer(path.read_bytes(), dtype=np.uint8)) def get_chat_client() -> OpenAI | None: try: OpenAI(base_url=f"{LM_STUDIO_URL}/v1", api_key="not-needed").models.list() return OpenAI(base_url=f"{LM_STUDIO_URL}/v1", api_key="not-needed") except Exception: return None def search_similar(query: str, k: int = FETCH_K): embeddings = load_embeddings() qvec = np.array([embeddings.embed_query(query)], dtype=np.float32) index = load_faiss_index(VECTOR_INDEX) scores, indices = index.search(qvec, k) with open(VECTOR_META, "rb") as f: data = pickle.load(f) metadatas = data["metadatas"] results = [] for score, idx in zip(scores[0], indices[0]): if idx < 0 or idx >= len(metadatas): continue results.append((metadatas[idx], float(score))) results.sort(key=lambda x: x[1], reverse=True) return results def rerank(query: str, results: list) -> list: reranker = load_reranker() pairs = [(query, r[0]["full_text"]) for r in results] scores = reranker.predict(pairs) scored = [(results[i][0], float(scores[i])) for i in range(len(results))] scored.sort(key=lambda x: x[1], reverse=True) return scored[:FINAL_K] def format_context(results) -> str: parts = [] for meta, score in results: parts.append( f"Тикет №{meta['ticket_id']} ({meta['category']}, {meta['client']}):\n{meta['full_text']}" ) return "\n\n---\n\n".join(parts) def generate_answer(chat: OpenAI, query: str, context: str) -> str: response = chat.chat.completions.create( model="gpt-3.5-turbo", messages=[ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": f"Контекст (история обращений):\n{context}\n\nВопрос клиента:\n{query}"}, ], temperature=0.1, max_tokens=2000, ) return response.choices[0].message.content def main(): st.set_page_config(page_title="RAG техподдержки", layout="wide") st.title("RAG-ассистент техподдержки") st.markdown( "Находит похожие обращения из истории и формирует ответ на их основе." ) if not VECTOR_INDEX.exists(): st.error("Векторный индекс не найден. Запусти: python prepare_data.py") st.stop() chat = get_chat_client() if chat is None: st.warning( "⚠️ Чат-модель не обнаружена. Загрузи модель в LM Studio." ) with st.form("query_form"): query = st.text_area( "Опишите проблему клиента:", placeholder="Например: клиент не может войти в ТГ-бот, пишет неверный логин", height=100, ) submitted = st.form_submit_button("Получить ответ", type="primary") if submitted and query: with st.spinner("Ищу похожие обращения..."): results = search_similar(query) if not results: st.warning("Не найдено похожих обращений") return with st.spinner("Оцениваю релевантность..."): results = rerank(query, results) if not results: st.warning("После проверки релевантности не осталось подходящих тикетов") return context = format_context(results) if chat: with st.spinner("Формирую ответ на основе найденных тикетов..."): try: answer = generate_answer(chat, query, context) st.subheader("💬 Ответ ассистента") st.markdown(answer) except Exception as e: st.error(f"Ошибка LLM: {e}") else: st.info("Результаты поиска без генерации ответа:") with st.expander("📄 Найденные похожие обращения", expanded=True): for meta, score in results: with st.container(border=True): st.markdown(f"### Тикет №{meta['ticket_id']} (релевантность: {score:.3f})") col1, col2 = st.columns(2) with col1: st.markdown(f"**Категория:** {meta['category']}") with col2: st.markdown(f"**Клиент:** {meta['client']}") st.text(meta["full_text"]) if __name__ == "__main__": main()