Files
fckbot/rag/utils/text_utils.py
Markov Andrey 63900feece Update 86 files
- /rag/commands/expert.py
- /core/services/__init__.py
- /core/services/embedding_service.py
- /core/services/file_service.py
- /core/services/giga_client.py
- /core/services/kb_service.py
- /core/services/postgres_service.py
- /core/services/qdrant_service.py
- /core/services/reranker_service.py
- /core/functions/__init__.py
- /core/functions/check_consistency.py
- /core/functions/check_spelling.py
- /core/functions/critique_answer.py
- /core/functions/expand_query.py
- /core/functions/extract_metrics.py
- /core/functions/file_processor.py
- /core/functions/generate_document.py
- /core/functions/intent_classify.py
- /core/functions/rerank_context.py
- /core/functions/summarize_document.py
- /core/utils/__init__.py
- /core/utils/arg_parser.py
- /core/utils/config_loader.py
- /core/utils/layout_converter.py
- /core/utils/logger.py
- /core/utils/text_utils.py
- /core/utils/web_utils.py
- /bots/commands/__init__.py
- /bots/commands/base.py
- /bots/commands/create.py
- /core/commands/global_remove.py
- /core/commands/help.py
- /core/commands/info.py
- /core/commands/kb.py
- /core/commands/learn.py
- /core/commands/other.py
- /core/commands/registry.py
- /core/commands/stats.py
- /core/commands/template.py
- /core/handlers/__init__.py
- /core/handlers/message_handler.py
- /core/workers/__init__.py
- /core/workers/indexing_worker.py
- /core/xmpp/__init__.py
- /core/xmpp/client.py
- /bots/commands/global_remove.py
- /bots/commands/help.py
- /bots/commands/info.py
- /bots/commands/kb.py
- /bots/commands/registry.py
- /bots/commands/other.py
- /bots/commands/template.py
- /bots/commands/learn.py
- /bots/commands/stats.py
- /bots/handlers/message_handler.py
- /bots/handlers/__init__.py
- /bots/workers/__init__.py
- /bots/workers/indexing_worker.py
- /bots/xmpp/__init__.py
- /bots/xmpp/client.py
- /rag/services/qdrant_service.py
- /rag/services/giga_client.py
- /rag/services/embedding_service.py
- /rag/services/reranker_service.py
- /rag/services/__init__.py
- /rag/services/file_service.py
- /rag/services/postgres_service.py
- /rag/services/kb_service.py
- /rag/functions/check_spelling.py
- /rag/functions/__init__.py
- /rag/functions/extract_metrics.py
- /rag/functions/generate_document.py
- /rag/functions/expand_query.py
- /rag/functions/critique_answer.py
- /rag/functions/file_processor.py
- /rag/functions/check_consistency.py
- /rag/functions/summarize_document.py
- /rag/functions/rerank_context.py
- /rag/functions/intent_classify.py
- /rag/utils/__init__.py
- /rag/utils/layout_converter.py
- /rag/utils/text_utils.py
- /rag/utils/arg_parser.py
- /rag/utils/config_loader.py
- /rag/utils/logger.py
- /rag/utils/web_utils.py
2026-06-30 10:33:28 +00:00

226 lines
12 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 -*-
"""
Утилиты для работы с текстом:
- Разбиение на чанки (токенизация) с помощью tiktoken для индексации документов.
- Подсчёт количества токенов в тексте (для обрезки истории диалога).
- Fallback-разбиение по символам, если tiktoken не установлен.
Используется в:
- KBService при индексации документов (split_into_chunks).
- RAG-пайплайне для обрезки истории диалога по токенам (count_tokens).
Особенности:
- Разбиение на предложения использует библиотеку razdel для корректной обработки
аббревиатур (т.е., и.т.д.) и других сложных случаев русского языка.
- Если razdel недоступен, используется fallback-регулярное выражение.
- Если tiktoken недоступен, используется грубая оценка токенов (3 символа на токен).
"""
import re
import logging
from typing import List
# ---- Инициализация токенизатора tiktoken (для точного подсчёта токенов) ----
try:
import tiktoken
# Используем кодировку cl100k_base, которая используется в моделях GPT-4, GPT-3.5 и GigaChat
TOKENIZER = tiktoken.get_encoding("cl100k_base")
except ImportError:
TOKENIZER = None
logging.warning("tiktoken не установлен. Для точного подсчёта токенов установите: pip install tiktoken")
# ----------------------------------------------------------------------------
# ---- Импорт razdel для умного разбиения по предложениям (русский язык) ----
try:
from razdel import sentenize
HAS_RAZDEL = True
except ImportError:
HAS_RAZDEL = False
logging.warning("razdel не установлен. Разбиение по предложениям будет неточным. Установите: pip install razdel")
# ----------------------------------------------------------------------------
logger = logging.getLogger(__name__)
def count_tokens(text: str) -> int:
"""
Подсчитывает количество токенов в тексте, используя tiktoken (если доступен).
Если tiktoken недоступен, использует грубую оценку: 1 токен ≈ 3 символа.
Эта функция используется в RAG-пайплайне для обрезки истории диалога
до заданного лимита токенов (max_context_tokens).
Аргументы:
text (str): текст для подсчёта токенов.
Возвращает:
int: количество токенов (или оценка).
"""
if not text:
return 0
if TOKENIZER is not None:
# Точный подсчёт через tiktoken
return len(TOKENIZER.encode(text))
else:
# Грубая оценка: для русского языка в среднем 1 токен ≈ 2.53 символа.
# Используем 3 как консервативную оценку (чтобы не превысить лимит).
return len(text) // 3 + 1
def split_into_chunks(
text: str,
chunk_size_tokens: int = 200,
overlap_tokens: int = 50,
approx_chunk_chars: int = 500,
approx_overlap_chars: int = 100,
strategy: str = "recursive_split_by_sentences"
) -> List[str]:
"""
Разбивает текст на чанки (фрагменты) для последующей индексации в векторной БД.
Сначала пытается использовать tiktoken для точного разбиения по токенам.
Если tiktoken недоступен, использует приближённое разбиение по символам.
Аргументы:
text (str): исходный текст.
chunk_size_tokens (int): целевой размер чанка в токенах (по умолчанию 200).
overlap_tokens (int): перекрытие между соседними чанками в токенах (по умолчанию 50).
approx_chunk_chars (int): размер чанка в символах для fallback-режима (по умолчанию 500).
approx_overlap_chars (int): перекрытие в символах для fallback-режима (по умолчанию 100).
strategy (str): стратегия разбиения (пока только "recursive_split_by_sentences").
Возвращает:
List[str]: список чанков (текстовых фрагментов).
"""
if not text or not text.strip():
return []
# Нормализуем пробелы: заменяем множественные пробелы, табуляции и переносы на один пробел.
# Это упрощает дальнейшую обработку и улучшает качество разбиения.
text = re.sub(r'\s+', ' ', text).strip()
# Если токенизатор недоступен, используем fallback-разбиение по символам.
if TOKENIZER is None:
logger.debug("tiktoken недоступен, используем разбиение по символам")
return _split_by_chars(text, approx_chunk_chars, approx_overlap_chars)
# Основной метод: разбиение по предложениям с учётом токенов.
return _split_by_tokens(text, chunk_size_tokens, overlap_tokens)
def _split_by_tokens(text: str, chunk_size: int, overlap: int) -> List[str]:
"""
Разбивает текст на чанки, используя tiktoken для точного подсчёта токенов.
Алгоритм:
1. Разбить текст на предложения (с помощью razdel для русского языка).
2. Накопить предложения, пока сумма токенов не превысит chunk_size.
3. Когда превышает сохранить текущий чанк и создать перекрытие из последних предложений (overlap токенов).
4. Продолжить с оставшимся хвостом.
5. В конце объединить очень короткие чанки (< chunk_size/3) с предыдущим,
чтобы избежать множества мелких фрагментов.
Аргументы:
text (str): исходный текст.
chunk_size (int): максимальный размер чанка в токенах.
overlap (int): количество токенов перекрытия между соседними чанками.
Возвращает:
List[str]: список чанков.
"""
# Вспомогательная функция для подсчёта токенов в строке.
def _count_tokens(s: str) -> int:
return len(TOKENIZER.encode(s))
# ---- 1. Разбиение на предложения ----
if HAS_RAZDEL:
# razdel возвращает итератор объектов Sentence, у которых есть атрибут .text
sentences = [sent.text for sent in sentenize(text)]
else:
# Fallback: разбиение по точкам, вопросительным и восклицательным знакам.
# Это может быть неточным для аббревиатур (например, "т.е.", "и.т.д.").
sentences = re.split(r'(?<=[.!?])\s+', text)
# -------------------------------------
chunks = []
current_chunk = [] # Список предложений текущего чанка
current_len = 0 # Суммарное количество токенов в текущем чанке
for sent in sentences:
sent_tokens = _count_tokens(sent) # Токены в текущем предложении
# Проверяем, помещается ли предложение в текущий чанк
if current_len + sent_tokens <= chunk_size:
# Помещается добавляем
current_chunk.append(sent)
current_len += sent_tokens
else:
# Не помещается сохраняем текущий чанк
if current_chunk:
chunks.append(' '.join(current_chunk))
# --- Формируем перекрытие для следующего чанка ---
# Берём последние предложения текущего чанка, сумма токенов которых
# не превышает overlap (чтобы сохранить контекст между чанками).
overlap_sentences = []
overlap_len = 0
# Идём с конца текущего чанка (от последнего предложения к первому)
for prev_sent in reversed(current_chunk):
prev_len = _count_tokens(prev_sent)
if overlap_len + prev_len <= overlap:
overlap_sentences.insert(0, prev_sent) # Вставляем в начало, чтобы сохранить порядок
overlap_len += prev_len
else:
break
# -------------------------------------------------
# Новый чанк начинается с перекрытия + текущее предложение
current_chunk = overlap_sentences + [sent]
current_len = overlap_len + sent_tokens
# Добавляем последний чанк, если он не пуст
if current_chunk:
chunks.append(' '.join(current_chunk))
# ---- Склеиваем очень короткие чанки с предыдущим ----
# Это предотвращает создание большого количества мелких фрагментов,
# которые могут ухудшить качество поиска.
merged = []
for chunk in chunks:
# Если текущий чанк слишком короткий (< 1/3 от chunk_size) и есть предыдущий чанк,
# присоединяем его к предыдущему.
if merged and _count_tokens(chunk) < chunk_size // 3:
merged[-1] = merged[-1] + " " + chunk
else:
merged.append(chunk)
# -------------------------------------------------
return merged
def _split_by_chars(text: str, chunk_size: int, overlap: int) -> List[str]:
"""
Fallback-метод: разбиение по символам (при отсутствии tiktoken).
Использует простое скользящее окно с фиксированной длиной chunk_size и перекрытием overlap.
Аргументы:
text (str): исходный текст.
chunk_size (int): размер чанка в символах.
overlap (int): перекрытие в символах.
Возвращает:
List[str]: список чанков.
"""
if chunk_size <= 0:
return [text]
chunks = []
start = 0
while start < len(text):
end = min(start + chunk_size, len(text))
chunks.append(text[start:end])
start = end - overlap
if start < 0:
start = 0
if start >= len(text):
break
return chunks