Логика и части кода приводятся из функция № 598 в составе платформы ProTalk


Представьте, что вам нужно найти ответ на конкретный вопрос — не в интернете, а внутри закрытой корпоративной базы: сотни PDF-файлов, таблиц Excel, презентаций и внутренних сайтов. Обычный поиск по ключевым словам даст кучу мусора или не найдёт ничего, если вы написали вопрос «не теми словами».

Именно для этого была создана технология RAG — Retrieval-Augmented Generation, «поиск с последующей генерацией». Это не просто поиск и не просто ИИ — это их комбинация. Система сначала находит нужные фрагменты текста, а потом объясняет их на человеческом языке.

«Представьте опытного референта, который перед ответом на ваш вопрос сначала прочитал всю нужную документацию — и теперь говорит своими словами, но строго по источникам.»


1. Подготовка: документы превращаются в числа

Прежде чем отвечать на вопросы, система должна «прочитать» все документы и запомнить их особым образом. Это происходит один раз при первой загрузке файлов.

Шаг 1. Извлечение текста

Программа умеет читать практически любой формат: PDF, Word, Excel, PowerPoint, CSV-таблицы, HTML-страницы, даже исходный код. У каждого формата своя «читалка» — но на выходе всегда получается обычный текст.

# Словарь «читалок» для разных форматов файлов
SUPPORTED = {
    '.pdf':  lambda f: ''.join(p.extract_text() or '' for p in PyPDF2.PdfReader(f).pages),
    '.docx': lambda f: '\n'.join(p.text for p in Document(f).paragraphs),
    '.pptx': lambda f: '\n'.join(
        shape.text
        for slide in Presentation(f).slides
        for shape in slide.shapes
        if hasattr(shape, "text") and shape.text.strip()
    ),
    '.xlsx': extract_xlsx,
    '.csv':  extract_csv,
    # и ещё десяток форматов — .txt, .json, .py, .js и т.д.
}

Аналогия. Это как если бы вы получили стопку документов разного вида — газеты, рукописи, распечатки — и переписали всё в единый список. Формат не важен, важно содержание.


Шаг 2. Нарезка на фрагменты

Целый документ нельзя отдать ИИ целиком — это слишком много. Поэтому текст нарезается на чанки — небольшие смысловые кусочки по несколько абзацев. Но нарезка делается умно, не механически.

Сначала система определяет тип контента:

def detect_content_type(text):
    """Определяет тип контента: table, code или prose."""
    # Это HTML-таблица или TSV-данные (много табуляций)?
    if re.search(r'<table|</td>|<tr>', text, re.IGNORECASE):
        return 'table'
    tab_lines = sum(1 for line in text.splitlines() if '\t' in line)
    if tab_lines > max(3, len(text.splitlines()) * 0.4):
        return 'table'

    # Это программный код?
    if re.search(r'function\s+\w+\s*\(|\bdef\s+\w+\s*\(|\bclass\s+\w+\b', text):
        return 'code'

    # Иначе — обычный текст (prose)
    return 'prose'

В зависимости от типа применяется разная стратегия нарезки:

  • Таблица → режем по строкам, но копируем заголовок в каждый кусок (чтобы данные не потеряли контекст)

  • Код → режем по пустым строкам между функциями/классами, не разрывая логику

  • Обычный текст → ищем смысловые границы между темами (подробнее ниже)

# Для таблиц: сохраняем заголовок в каждом чанке
def split_table_by_rows(text, chunk_size):
    lines = [l for l in text.splitlines() if l.strip()]
    header = lines[0]  # первая строка — заголовок
    chunks = []
    cur_lines = [header]
    for line in lines[1:]:
        if sum(len(l) for l in cur_lines) + len(line) >= chunk_size:
            chunks.append('\n'.join(cur_lines))
            cur_lines = [header, line]  # ← заголовок всегда остаётся
        else:
            cur_lines.append(line)
    if cur_lines:
        chunks.append('\n'.join(cur_lines))
    return chunks

Аналогия. Представьте, что вы готовите картотеку. Каждая карточка — один чанк. Вы не хотите, чтобы начало одной темы попало на карточку с концом другой. Поэтому вы аккуратно разделяете текст там, где меняется смысл.


Шаг 3. Семантическая нарезка: ищем смысловые границы

Для обычного текста нарезка делается не по символам, а по смыслу. Алгоритм разбивает текст на предложения, получает эмбеддинги каждого (подробнее о них — в следующем шаге) и ищет места, где смысл резко меняется.

# Упрощённая схема семантической нарезки
sims = []
for i in range(len(sentences) - 1):
    # Косинусное сходство между соседними предложениями
    sims.append(float(np.dot(sent_embs[i], sent_embs[i + 1])))

# Адаптивный порог: нижний квартиль сходств
adaptive_threshold = float(np.percentile(sims, 25))

# Там, где сходство падает ниже порога — граница между темами
boundaries = {i + 1 for i, sim in enumerate(sims) if sim < adaptive_threshold}

Если сходство между двумя соседними предложениями резко падает — значит, тема сменилась. Здесь и проходит граграница чанка.


Шаг 4. Превращение текста в числа (эмбеддинги)

Каждый фрагмент отправляется в специальную нейросеть, которая возвращает вектор — список из 1536 чисел. Этот вектор — математическое «описание смысла» фрагмента.

def get_embeddings(texts, batch_size=100):
    embeddings = []
    for i in range(0, len(texts), batch_size):
        resp = requests.post(
            "https://openrouter.ai/api/v1/embeddings",
            headers=HEADERS,
            json={
                "model": "openai/text-embedding-3-small",
                "input": texts[i:i + batch_size],
                "encoding_format": "float"
            },
        ).json()
        embeddings.extend(d["embedding"] for d in resp.get("data", []))
    return np.array(embeddings)

Аналогия. Представьте систему координат, где каждая тема — это направление в пространстве. «Бухгалтерия» — одно направление, «технологии» — другое. Текст про налоги окажется близко к «бухгалтерии». Два похожих по смыслу текста — близко друг к другу в этом пространстве, даже если написаны разными словами. Это и есть суть смыслового поиска.

Все векторы сохраняются в базу данных вместе с исходным текстом чанков. Подготовка завершена.


2. Поиск: как система находит нужное

Когда пользователь задаёт вопрос, начинается многоэтапный поиск. Просто сравнить слова — недостаточно. Система использует сразу несколько методов.

Шаг 1. Вопрос тоже превращается в вектор

Вопрос пользователя проходит через ту же нейросеть. Теперь у нас есть «математический портрет» вопроса — и мы можем искать похожие портреты среди чанков.

Шаг 2. Переформулировки вопроса

ИИ генерирует 3 разных версии вашего вопроса — с другими словами, но тем же смыслом. Это помогает найти фрагменты, которые используют иную терминологию.

# Запрос к ИИ: придумай 3 переформулировки
exp_resp, _ = call_ai_with_retry(
    "Сгенерируй 3 разных переформулировки следующего вопроса для поиска в базе знаний. "
    "Пиши только переформулировки, каждую с новой строки, без нумерации.\nВопрос: " + query,
    rerank_model,
)
# Затем все варианты тоже превращаются в векторы и ищутся параллельно
expansion_variants = [v.strip() for v in exp_resp["choices"][0]["message"]["content"].split('\n')]

Шаг 3. Двойной поиск — смысловой и по ключевым словам

Система ищет одновременно двумя способами:

Смысловой поиск (векторный): находит фрагменты, близкие по смыслу, даже с другими словами.

Поиск по ключевым словам (BM25): классический алгоритм, похожий на поиск Google. Хорошо находит точные совпадения, числа, имена.

# BM25: поиск по ключевым словам
# k1 и b — стандартные параметры алгоритма
k1, b = 1.5, 0.75
avgdl = sum(len(c['content'].split()) for c in chunks_data) / N

for chunk in chunks_data:
    text_words = re.findall(r'\w+', chunk['content'].lower())
    tf = defaultdict(int)
    for w in text_words:
        tf[w] += 1
    doc_len = len(text_words)
    # Итоговый BM25-score для чанка
    score = sum(
        idf.get(w, 0) * (tf[w] * (k1 + 1)) / (tf[w] + k1 * (1 - b + b * doc_len / avgdl))
        for w in query_words
    )

Шаг 4. Объединение результатов через RRF

Результаты обоих поисков объединяются по формуле RRF (Reciprocal Rank Fusion) — она «голосует» между методами. Фрагменты, которые хорошо себя показали сразу в нескольких поисках, поднимаются наверх.

# RRF: чем выше ранг в каждом поиске — тем больше очков
# k_rrf — параметр сглаживания (60 по умолчанию)
for rank, idx in enumerate(sorted_by_vector):
    rrf_scores[idx] += 1.0 / (k_rrf + rank)

for rank, idx in enumerate(sorted_by_bm25):
    rrf_scores[idx] += bm25_normalized[idx] * (1.0 / (k_rrf + rank))

Аналогия. Представьте, что у вас два эксперта и каждый составил свой рейтинг нужных страниц. RRF — это как взять оба рейтинга и выдать победителя по сумме мест: кто стабильно высоко у обоих, тот и первый.

Шаг 5. Финальная фильтрация через ИИ (ре-ранкинг)

Топ-50 кандидатов отправляются ИИ с вопросом: «Какие из этих фрагментов реально полезны для ответа?» ИИ выбирает только самые релевантные — обычно 5–10 штук.

# Отправляем кандидатов ИИ для финального отбора
candidate_texts = [f"[{i+1}]: {chunk['content'][:3000]}" for i, chunk in enumerate(top_candidates)]

prompt = (
    "Ты эксперт. Выбери строго минимальное количество лучших фрагментов, "
    "достаточных для ответа на вопрос.\n"
    f"Вопрос: {query}\n\n"
    f"Фрагменты:\n{chr(10).join(candidate_texts)}\n\n"
    f"Верни номера (через запятую) ТОЛЬКО самых релевантных фрагментов (максимум {ask_count}). "
    "Если релевантных нет — верни 'NONE'.\nОтвет:"
)

Особый случай: цитаты и точные числа

Если в вопросе есть число или фраза в кавычках — система принудительно включает все фрагменты с точным совпадением, независимо от рейтинга.

# Если в вопросе есть кавычки — ищем точное совпадение в чанках
quote_matches = re.findall(r'«([^»]+)»|"([^"]+)"', query)
for match in quote_matches:
    quote = (match[0] or match[1]).strip()
    if len(quote) > 5:
        for i, chunk in enumerate(chunks_data):
            if quote in chunk['content']:
                forced_indices.append(i)  # принудительно включаем этот чанк
                break

# Аналогично для чисел из вопроса
query_numbers = re.findall(r'\b\d+\b', query)
for num in query_numbers:
    pattern = re.compile(rf'\b(№|N|номер)\s*{num}\b', re.IGNORECASE)
    for i, chunk in enumerate(chunks_data):
        if pattern.search(chunk['content']):
            forced_indices.append(i)

«Система знает, когда вы ищете конкретный факт — и ведёт себя иначе, чем когда вы просите объяснить принцип.»


3. Умные надстройки

Помимо базового поиска, система включает ряд продвинутых механизмов.

Иерархический поиск: сначала документ, потом фрагмент

Если документов очень много, полный перебор всех чанков был бы медленным. Система сначала находит нужные документы, а потом ищет фрагменты только внутри них.

# Этап 1: находим топ-5 наиболее релевантных документов
# (берём первый чанк каждого как "аннотацию")
doc_summaries = [chunks_data_by_doc[doc_id][0]['content'][:500] for doc_id in all_doc_ids]
doc_embs = get_embeddings(doc_summaries)
sims = np.dot(doc_embs_normalized, query_embedding_normalized)
top_doc_ids = [all_doc_ids[i] for i in np.argsort(sims)[::-1][:5]]

# Этап 2: ищем чанки только среди выбранных документов
chunks_data = [ch for ch in chunks_data if ch['doc_id'] in top_doc_ids]

Аналогия. Как искать в нужном ящике картотеки, а не перебирать все ящики подряд.

Итеративный поиск: несколько раундов

Если вопрос сложный («сравни А и Б» или «почему...»), одного поиска может не хватить. Система делает несколько раундов.

# После первого поиска спрашиваем ИИ: чего ещё не хватает?
prompt = (
    "Если информации достаточно — верни 'NONE'. "
    "Если не хватает, сформулируй уточняющий под-запрос.\n"
    f"Исходный вопрос: {base_query}\n\nКонтекст:\n{combined_context}\n\nПод-запрос или NONE:"
)
resp, _ = call_ai_with_retry(prompt, ai_model)
follow_up = resp["choices"][0]["message"]["content"].strip()

if "NONE" not in follow_up.upper():
    # Делаем ещё один поиск по уточнённому запросу
    follow_context, follow_used, _ = get_top_context_advanced(
        chunks_data, follow_up, get_embeddings([follow_up])[0], ...
    )
    combined_context += "\n\n" + follow_context

Расширение контекста: берём соседей

Когда нашёлся нужный фрагмент, система автоматически прихватывает соседние — если они по смыслу близки. Это помогает не потерять важный контекст вокруг ответа.

def get_adaptive_neighbors(idx, sim_threshold=0.7, max_window=3):
    """Расширяем контекст в обе стороны, пока соседние чанки
    остаются достаточно похожи по смыслу."""
    chunk = chunks_data[idx]
    doc_id = chunk['doc_id']
    cur_idx = chunk['chunk_index']
    neighbors = []

    for step in range(1, max_window + 1):
        next_idx = cur_idx + step
        # Считаем косинусное сходство с предыдущим чанком
        sim = float(np.dot(chunk_embs_norm[cur_idx + step - 1],
                           chunk_embs_norm[cur_idx + step]))
        if sim < sim_threshold:
            break  # тема сменилась — дальше не идём
        neighbors.append(next_idx)

    return neighbors

Разнообразие результатов (MMR)

Алгоритм MMR (Maximal Marginal Relevance) следит, чтобы выбранные фрагменты не повторяли друг друга. Если два куска говорят об одном — берётся только один.

mmr_selected = []
for idx in sorted_by_rrf_score:
    if len(mmr_selected) >= 50:
        break
    if not mmr_selected:
        mmr_selected.append(idx)
        continue
    # Проверяем: насколько этот чанк похож на уже выбранные?
    current_emb = chunk_embeddings_norm[idx]
    is_diverse = all(
        np.dot(current_emb, chunk_embeddings_norm[sel]) < 0.85
        for sel in mmr_selected
    )
    if is_diverse:
        mmr_selected.append(idx)  # берём только если достаточно уникален

HyDE: поиск через гипотетический ответ

ИИ сначала фантазирует: «Как мог бы выглядеть идеальный ответ?» — и ищет реальные фрагменты, похожие на эту фантазию. Помогает при нестандартных или абстрактных вопросах.

# Генерируем гипотетический ответ
hyde_resp, _ = call_ai_with_retry(
    "Напиши подробный гипотетический ответ на следующий вопрос, "
    "как если бы ты нашёл его в базе знаний. "
    f"Вопрос: {query}",
    rerank_model,
)
hyde_text = hyde_resp["choices"][0]["message"]["content"].strip()

# Превращаем его в вектор и добавляем к поиску
hyde_emb = get_embeddings([hyde_text])[0]
expanded_embeddings.append(hyde_emb)

Кэширование повторных запросов

Если тот же вопрос уже задавали — система мгновенно отдаёт сохранённый результат без повторного поиска.

import hashlib

def make_cache_key(query_text, vs_id):
    # Уникальный ключ = хэш от вопроса + ID базы знаний
    return hashlib.sha256(f"{query_text}|{vs_id}".encode("utf-8")).hexdigest()

# Проверяем кэш перед поиском
_cache_key = make_cache_key(query, vector_store_id)
_cached = cache_get(_cache_key)  # смотрим в базе данных

if _cached:
    context, used_chunks = _cached  # мгновенный ответ!
else:
    context, used_chunks = iterative_retrieve(...)  # полный поиск
    cache_set(_cache_key, query, vector_store_id, context, used_chunks)

4. Финальный ответ

После того как лучшие фрагменты найдены, они передаются языковой модели вместе с исходным вопросом. Модель получает чёткую инструкцию: работать строго по источникам.

final_prompt = (
    "Ты — эксперт по базам знаний. Используй ТОЛЬКО информацию из контекста ниже.\n"
    "Дай развернутый ответ на вопрос. Если нужно сравнить что-то — "
    "опиши различия, преимущества и т.д.\n"
    "Не добавляй ничего от себя.\n\n"
    f"Контекст:\n{context}\n\n"
    f"Вопрос: {query}\n"
    f"Ответ:"
)
resp, model_used = call_ai_with_retry(final_prompt, ai_model)

Это принципиально важно: ИИ здесь работает не как «всезнайка», а как переводчик — он берёт найденную информацию и излагает её понятным языком. Это защищает от «галлюцинаций» — когда ИИ выдумывает несуществующие факты.

Если ни одна модель не ответила (сбой, лимиты), система возвращает найденный контекст как есть — без потери данных:

if resp is None:
    return {
        "answer": None,
        "fallback": True,
        "fallback_reason": "all_ai_requests_failed",
        "context": context,   # контекст всё равно возвращается
        "chunks": used_chunks,
    }

Итоговая аналогия. Представьте двух специалистов. Первый — опытный библиотекарь, который мгновенно находит нужные страницы в тысячах книг. Второй — блестящий редактор, который читает эти страницы и объясняет суть простым языком. RAG — это их совместная работа.


Главное за 30 секунд

  1. Документы загружаются один раз. Система читает файлы, нарезает их на фрагменты и превращает каждый в математический «отпечаток смысла».

  2. Вопрос тоже получает свой «отпечаток». Система ищет фрагменты, чей смысл математически близок к вопросу — даже если слова другие.

  3. Несколько методов поиска работают вместе. Смысловой поиск, ключевые слова, ИИ-фильтрация — результаты объединяются для максимальной точности.

  4. ИИ строго по источникам составляет ответ. Никаких догадок — только то, что нашлось в документах, пересказанное понятным языком.

В результате пользователь получает точный ответ со ссылкой на источник — за секунды, вместо часов ручного поиска по документации.

Бот ответит, опираясь строго на загруженные материалы — именно так, как описано в этой статье.