Files
fckbot/rag/services/kb_service.py
2026-06-30 12:01:03 +00:00

309 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.
# -*- coding: utf-8 -*-
"""
Сервис базы знаний (Knowledge Base Service).
Реализует RAG-пайплайн:
- Индексация документов (разбиение на чанки, получение эмбеддингов, сохранение в Qdrant и PostgreSQL).
- Поиск релевантной информации по запросу (семантический поиск в Qdrant с фильтром по room_jid).
- Управление доступом к документам (личные / глобальные / комнатные).
- Поддержка обновления версий документов в комнатах (замена по хешу).
Добавлена транзакционность индексации через флаг indexed в PostgreSQL.
"""
import asyncio
import logging
import re
from typing import List, Dict, Any, Optional, Tuple
# Импорт сервисов
from core.services.postgres_service import PostgresService
from core.services.qdrant_service import QdrantService
from core.services.embedding_service import EmbeddingService
from core.utils.text_utils import split_into_chunks
logger = logging.getLogger(__name__)
class KBService:
"""
База знаний: индексация документов, поиск, управление.
Абстрагирует работу с PostgreSQL и Qdrant для RAG.
"""
def __init__(
self,
db: PostgresService,
qdrant: QdrantService,
embedding: EmbeddingService,
collection_name: str,
chunk_size_tokens: int,
overlap_tokens: int,
approx_chunk_chars: int,
approx_overlap_chars: int,
):
"""
Инициализация KBService.
Аргументы:
db: сервис PostgreSQL (asyncpg)
qdrant: сервис Qdrant
embedding: сервис эмбеддингов GigaChat
collection_name: имя коллекции в Qdrant
chunk_size_tokens: размер чанка в токенах (для tiktoken)
overlap_tokens: перекрытие между чанками в токенах
approx_chunk_chars: запасной размер чанка в символах (если tiktoken недоступен)
approx_overlap_chars: запасное перекрытие в символах
"""
self.db = db
self.qdrant = qdrant
self.embedding = embedding
self.collection_name = collection_name
self.chunk_size_tokens = chunk_size_tokens
self.overlap_tokens = overlap_tokens
self.approx_chunk_chars = approx_chunk_chars
self.approx_overlap_chars = approx_overlap_chars
# ------------------------------------------------------------------
# Поиск релевантной информации
# ------------------------------------------------------------------
async def find_relevant_info(self, query: str, user_jid: str, room_jid: str = None, top_k: int = 15) -> str:
"""
Находит релевантные фрагменты в БЗ.
Фильтрация по indexed = TRUE уже происходит на уровне Qdrant (фильтр доступа),
но дополнительно мы не возвращаем документы, которые не проиндексированы.
"""
try:
# Определяем, является ли запрос "точным"
is_exact_query = bool(re.search(r'\d+\.\d+', query)) or '"' in query
if is_exact_query:
# Полнотекстовый поиск (использует фильтр indexed=TRUE в Qdrant)
search_results = self.qdrant.search_keywords(query, user_jid, room_jid, top_k=top_k)
else:
# Получаем dense-вектор запроса
query_vector = await self.embedding.embed(query)
if query_vector is None:
logger.error("Не удалось получить dense-эмбеддинг запроса, переключаемся на fallback")
return await self._fallback_search(query, user_jid, room_jid)
sparse_vector = await self.embedding.embed_sparse(query)
if sparse_vector is None:
logger.warning("Не удалось получить sparse-вектор запроса, используем только dense-поиск")
search_results = self.qdrant.search_hybrid(
dense_vector=query_vector,
sparse_vector=sparse_vector,
user_jid=user_jid,
room_jid=room_jid,
top_k=top_k
)
if not search_results:
logger.info(f"По запросу '{query[:50]}' ничего не найдено в Qdrant")
return await self._fallback_search(query, user_jid, room_jid)
context_parts = []
for res in search_results:
payload = res.get('payload', {})
source_name = payload.get('source_name', 'Неизвестный источник')
chunk_text = payload.get('text', '')
if chunk_text:
context_parts.append(f"[источник: {source_name}]\n{chunk_text}")
context = "\n\n".join(context_parts)
logger.debug(f"Сформирован контекст длиной {len(context)} символов")
return context
except Exception as e:
logger.exception(f"Ошибка при поиске в RAG: {e}")
return await self._fallback_search(query, user_jid, room_jid)
async def _fallback_search(self, query: str, user_jid: str, room_jid: str = None) -> str:
"""
Полнотекстовый поиск по source_name в PostgreSQL (только indexed=TRUE).
Используется, если Qdrant недоступен или не вернул результатов.
"""
if not query or len(query.strip()) < 3:
return ""
clean_query = re.sub(r'[^\w\s]', ' ', query)
try:
rows = await self.db.fulltext_search(clean_query, user_jid, room_jid)
if not rows:
return ""
parts = [
f"[источник: {row['source_name']}]\n(Документ доступен, но текст не проиндексирован в Qdrant. Повторите запрос позже.)"
for row in rows
]
return "\n\n".join(parts)
except Exception as e:
logger.error(f"Ошибка при fallback поиске: {e}")
return ""
# ------------------------------------------------------------------
# Индексация документов (транзакционная)
# ------------------------------------------------------------------
async def add_document(
self,
file_name: str,
file_text: str,
user_jid: str,
is_global: bool,
title: Optional[str] = None,
metadata: Optional[Dict] = None,
room_jid: str = None,
file_hash: str = None,
update_if_exists: bool = True
) -> Tuple[int, int]:
"""
Индексирует документ в базу знаний с транзакционностью:
1. Создаёт запись в PostgreSQL с indexed = False.
2. Разбивает на чанки, получает эмбеддинги, загружает в Qdrant.
3. Если успешно обновляет indexed = True.
4. При ошибке оставляет indexed = False (можно будет повторить).
"""
source_name = title if title else file_name
doc_meta = metadata or {}
if file_hash:
doc_meta['file_hash'] = file_hash
# --- ОБНОВЛЕНИЕ ДОКУМЕНТА В КОМНАТЕ (замена версии) ---
if update_if_exists and room_jid and not is_global:
existing = await self.db.find_document_by_name_and_room(source_name, room_jid)
if existing:
old_doc_id = existing['id']
old_hash = existing.get('file_hash')
if file_hash and old_hash == file_hash:
logger.info(f"Документ {source_name} не изменился (хеш совпадает), пропускаем индексацию")
return old_doc_id, 0
# Удаляем старый документ из Qdrant и PostgreSQL
logger.info(f"Обновление документа {source_name} в комнате {room_jid}: удаляем старую версию doc_id={old_doc_id}")
self.qdrant.delete_by_doc_id(old_doc_id)
# Удаляем запись из БД (включая права доступа)
await self.db.delete_documents_by_ids([old_doc_id])
# --- СОЗДАНИЕ ЗАПИСИ В POSTGRESQL С indexed=False ---
doc_id = await self.db.add_document(
source_name=source_name,
owner_jid=user_jid,
is_global=is_global,
collection_name=self.collection_name,
metadata=doc_meta,
room_jid=room_jid,
file_hash=file_hash,
indexed=False # начальное состояние не проиндексирован
)
# --- РАЗБИЕНИЕ НА ЧАНКИ ---
chunks = split_into_chunks(
file_text,
chunk_size_tokens=self.chunk_size_tokens,
overlap_tokens=self.overlap_tokens,
approx_chunk_chars=self.approx_chunk_chars,
approx_overlap_chars=self.approx_overlap_chars,
strategy="recursive_split_by_sentences"
)
if not chunks:
# Если нет чанков значит, документ пустой. Удаляем запись.
await self.db.delete_documents_by_ids([doc_id])
logger.warning(f"Документ {source_name} пуст, индексация отменена")
return doc_id, 0
# --- ПАКЕТНОЕ ПОЛУЧЕНИЕ ЭМБЕДДИНГОВ (dense + sparse) ---
dense_vectors = await self.embedding.embed_batch(chunks)
if dense_vectors is None or len(dense_vectors) != len(chunks):
logger.error(f"Не удалось получить dense-эмбеддинги для документа {doc_id}, оставляем indexed=False")
# Можно также удалить запись, но оставим для возможности повтора
return doc_id, 0
sparse_vectors = await self.embedding.embed_sparse_batch(chunks)
# --- СОХРАНЕНИЕ ЧАНКОВ В QDRANT ---
chunk_count = 0
success = True
try:
for idx, chunk in enumerate(chunks):
dense_vec = dense_vectors[idx] if idx < len(dense_vectors) else None
if dense_vec is None:
logger.error(f"Нет dense-вектора для чанка {idx}, прерываем индексацию")
success = False
break
sparse_vec = None
if sparse_vectors and idx < len(sparse_vectors):
sparse_vec = sparse_vectors[idx]
payload = {
"doc_id": doc_id,
"user_jid": user_jid,
"is_global": is_global,
"chunk_index": idx,
"text": chunk,
"source_name": source_name,
"room_jid": room_jid if room_jid else "",
}
await asyncio.to_thread(
self.qdrant.add_chunk,
dense=dense_vec,
sparse=sparse_vec,
payload=payload
)
chunk_count += 1
except Exception as e:
logger.error(f"Ошибка при добавлении чанков в Qdrant для doc_id={doc_id}: {e}")
success = False
if success and chunk_count > 0:
# Все чанки загружены успешно обновляем флаг
await self.db.update_document_indexed(doc_id, True)
# Для личных документов предоставляем доступ владельцу
if not is_global and not room_jid:
await self.db.add_access(doc_id, user_jid)
logger.info(f"Документ {source_name} (doc_id={doc_id}) проиндексирован, чанков: {chunk_count}")
else:
# Не удалось загрузить все чанки оставляем indexed=False
logger.error(f"Документ {source_name} (doc_id={doc_id}) не проиндексирован (чанков: {chunk_count})")
# Можно также удалить запись, но оставим для диагностики
return doc_id, chunk_count
# ------------------------------------------------------------------
# Получение списков документов (используют методы db с фильтром indexed=TRUE)
# ------------------------------------------------------------------
async def get_available_sources(self, user_jid: str, room_jid: str = None) -> List[str]:
"""Возвращает список названий документов (source_name), доступных пользователю (только indexed=TRUE)."""
return await self.db.get_available_sources(user_jid, room_jid)
async def get_sources_with_type(self, user_jid: str, room_jid: str = None) -> List[Dict[str, Any]]:
"""Возвращает список документов с типом (только indexed=TRUE)."""
return await self.db.get_sources_with_type(user_jid, room_jid)
# ------------------------------------------------------------------
# Очистка баз знаний (удаление всех документов, независимо от indexed)
# ------------------------------------------------------------------
async def clear_user_kb(self, user_jid: str) -> None:
"""Удаляет все личные документы пользователя."""
doc_ids = await self.db.clear_user_kb(user_jid)
for doc_id in doc_ids:
self.qdrant.delete_by_doc_id(doc_id)
logger.info(f"Личная БЗ пользователя {user_jid} очищена (документов: {len(doc_ids)})")
async def clear_room_kb(self, room_jid: str) -> None:
"""Удаляет все документы указанной комнаты."""
doc_ids = await self.db.clear_room_kb(room_jid)
for doc_id in doc_ids:
self.qdrant.delete_by_doc_id(doc_id)
logger.info(f"Комнатная БЗ {room_jid} очищена (документов: {len(doc_ids)})")
async def clear_global_kb(self) -> None:
"""Удаляет все глобальные документы."""
doc_ids = await self.db.clear_global_kb()
for doc_id in doc_ids:
self.qdrant.delete_by_doc_id(doc_id)
logger.info("Глобальная БЗ очищена")