ChatGPT на вашем ноутбуке: бесплатно, анонимно, навсегда
ChatGPT на вашем ноутбуке: бесплатно, анонимно, навсегда

Полная версия

Настройки чтения
Размер шрифта
Высота строк
Поля
На страницу:
7 из 9

Глава 4.4. Практикум:индексация папки с документами.

В предыдущих главах мы собрали все детали конструктора: embedding-модель превращает текст в векторы, ChromaDB хранит их и ищет. Теперь нам нужен «загрузчик» — функция, которая пройдётся по папке с вашими документами, прочитает файлы, разобьёт их на осмысленные куски, векторизует и сохранит в базу данных. Это и будет индексация — процесс, который выполняется один раз (или периодически) и делает вашу личную базу знаний доступной для вопросов.

Инструменты для чтения разных форматов

Мы хотим поддерживать три самых распространённых типа файлов:

· PDF — контракты, отчёты, сканы (если распознаны). Будем использовать библиотеку PyPDF2.

· DOCX — документы Microsoft Word. Используем python-docx.

· TXT — обычные текстовые файлы (читаются встроенными средствами Python).

Установим недостающие пакеты:

bash

pip install PyPDF2 python-docx langchain-text-splitters

langchain-text-splitters — это лёгкий пакет, который содержит умные разбиватели текста. Он не зависит от всего LangChain, только от langchain-core. Мы будем использовать RecursiveCharacterTextSplitter — он пытается разбить текст по абзацам, потом по предложениям, потом по словам, чтобы сохранить смысловую целостность.

Разбивка на чанки: почему это важно

Если мы скормим в ChromaDB целый 100-страничный PDF как один документ, поиск по нему будет бесполезен — вектор одного огромного текста плохо отражает детали. Нужно разбить текст на фрагменты, каждый из которых содержит законченную мысль.

Оптимальный размер чанка — примерно 500–1000 символов. Слишком маленькие чанки теряют контекст, слишком большие — размывают релевантность и не влезают в контекстное окно LLM. Мы также добавим перекрытие (overlap) — 100–200 символов, которые дублируются с предыдущим чанком. Это помогает сохранить связность: если мысль «переползла» через границу чанка, она будет видна в обоих.

Функция чтения файлов

Создадим модуль document_reader.py:

python

# document_reader.py

import os

from typing import List, Dict

import PyPDF2

from docx import Document

def read_pdf(file_path: str) -> str:

"""Извлекает текст из PDF-файла, страница за страницей."""

text_parts = []

with open(file_path, 'rb') as f:

reader = PyPDF2.PdfReader(f)

for page_num, page in enumerate(reader.pages, start=1):

page_text = page.extract_text()

if page_text:

text_parts.append(f"[Страница {page_num}] {page_text}")

return "\n".join(text_parts)

def read_docx(file_path: str) -> str:

"""Извлекает текст из DOCX-файла."""

doc = Document(file_path)

text_parts = [para.text for para in doc.paragraphs if para.text.strip()]

return "\n".join(text_parts)

def read_txt(file_path: str) -> str:

"""Читает простой текстовый файл с автоопределением кодировки."""

# Пробуем UTF-8, если не получается — cp1251 (Windows-кириллица)

try:

with open(file_path, 'r', encoding='utf-8') as f:

return f.read()

except UnicodeDecodeError:

with open(file_path, 'r', encoding='cp1251') as f:

return f.read()

def read_file(file_path: str) -> str:

"""

Определяет тип файла по расширению и возвращает его полный текст.

"""

ext = os.path.splitext(file_path)[1].lower()

if ext == '.pdf':

return read_pdf(file_path)

elif ext == '.docx':

return read_docx(file_path)

elif ext == '.txt':

return read_txt(file_path)

else:

raise ValueError(f"Неподдерживаемый формат: {ext}")

Пояснения :

· read_pdf нумерует страницы — это пригодится для метаданных и цитирования.

· read_docx извлекает только непустые параграфы, чтобы не засорять базу пустыми строками.

· read_txt обрабатывает распространённую проблему: файлы в кодировке Windows-1251 (часто встречаются в русскоязычных проектах).

Функция индексации папки

Теперь — главный модуль indexer.py, который объединяет всё вместе:

python

# indexer.py

import os

import uuid

from typing import List, Dict

from langchain_text_splitters import RecursiveCharacterTextSplitter

from document_reader import read_file

from embedder import Embedder

from vector_store import VectorStore

def index_folder(

folder_path: str,

store: VectorStore,

embedder: Embedder,

chunk_size: int = 800,

chunk_overlap: int = 150,

file_extensions: List[str] = ['.pdf', '.docx', '.txt']

) -> int:

"""

Рекурсивно обходит папку, читает все поддерживаемые файлы,

разбивает на чанки, векторизует и сохраняет в ChromaDB.

Возвращает общее количество добавленных чанков.

"""

# Создаём разбиватель текста

splitter = RecursiveCharacterTextSplitter(

chunk_size=chunk_size,

chunk_overlap=chunk_overlap,

separators=["\n\n", "\n", ". ", " ", ""] # порядок: от крупных разделителей к мелким

)

total_chunks = 0

# Обходим все файлы в папке и подпапках

for root, dirs, files in os.walk(folder_path):

for file_name in files:

ext = os.path.splitext(file_name)[1].lower()

if ext not in file_extensions:

continue # пропускаем неподдерживаемые форматы

file_path = os.path.join(root, file_name)

print(f"Обработка: {file_path}")

try:

# Читаем полный текст документа

full_text = read_file(file_path)

if not full_text.strip():

print(f" -> Пустой файл, пропущен.")

continue

# Разбиваем на чанки

chunks = splitter.split_text(full_text)

print(f" -> Получено чанков: {len(chunks)}")

# Готовим данные для пакетной вставки

ids = []

embeddings_list = []

documents = []

metadatas = []

for i, chunk in enumerate(chunks):

chunk_id = f"{file_path}_{i}_{uuid.uuid4().hex[:8]}"

ids.append(chunk_id)

documents.append(chunk)

metadatas.append({

"source": file_path,

"file_name": file_name,

"chunk_index": i

})

# Векторизуем сразу все чанки (batch-обработка)

embeddings_list = embedder.embed_documents(chunks)

# Сохраняем в ChromaDB

store.add_documents(

ids=ids,

embeddings=embeddings_list,

documents=documents,

metadatas=metadatas

)

total_chunks += len(chunks)

except Exception as e:

print(f" -> Ошибка: {e}")

continue

return total_chunks

Ключевые моменты :

· Мы используем RecursiveCharacterTextSplitter, который пробует разбивать текст по двойным переводам строки (абзацы), затем по одному, затем по точкам и пробелам. Так чанки получаются более «умными».

· Метаданные (source, file_name, chunk_index) позволят нам при ответе указать, из какого файла и какого места взят фрагмент.

· Векторизуем чанки пакетом: embedder.embed_documents(chunks). Это намного быстрее, чем векторизовать по одному, потому что модель обрабатывает список за один проход.

· Идентификатор чанка содержит путь к файлу и случайную часть, чтобы избежать коллизий.

Тестирование индексации

Создадим в проекте папку sample_docs/ с тестовыми файлами:

· test.txt — пара абзацев.

· test.pdf (можно создать из Word и сохранить как PDF).

· test.docx — любой документ.

Напишем тестовый скрипт:

python

# test_indexer.py

from embedder import Embedder

from vector_store import VectorStore

from indexer import index_folder

# 1. Инициализируем компоненты

print("Загрузка embedding-модели...")

emb = Embedder()

store = VectorStore(collection_name="test_docs")

# 2. Индексируем папку

print("Начинаем индексацию...")

count = index_folder(

folder_path="./sample_docs",

store=store,

embedder=emb,

chunk_size=500,

chunk_overlap=50

)

print(f"\nИндексация завершена. Всего чанков: {count}")

# 3. Поищем что-нибудь

query = "ваш тестовый запрос"

query_vec = emb.embed_query(query)

results = store.search(query_vec, n_results=3)

print("\nРезультаты поиска:")

for doc, meta in zip(results["documents"][0], results["metadatas"][0]):

print(f"- [{meta['file_name']}] {doc[:100]}...")

Запустим:

bash

python test_indexer.py

Вывод:

text

Загрузка embedding-модели intfloat/multilingual-e5-base...

Модель загружена, размерность векторов: 768

Начинаем индексацию...

Обработка: ./sample_docs/test.txt

-> Получено чанков: 2

Обработка: ./sample_docs/test.docx

-> Получено чанков: 3

Обработка: ./sample_docs/test.pdf

-> Получено чанков: 5

Индексация завершена. Всего чанков: 10

Результаты поиска:

- [test.pdf] ... текст, релевантный запросу...

Переиндексация и обновление данных

Что делать, когда документы меняются? Пока что мы просто добавляем новые чанки, но старые версии тех же файлов остаются в базе. Для начала этого достаточно: при повторном запуске index_folder мы создадим дубликаты. Чтобы решить проблему, перед индексацией можно полностью очищать коллекцию:

python

store.clear()

Или, как вариант, хранить время последней индексации и добавлять только новые/изменённые файлы. Для персонального использования очистка и переиндексация с нуля — самый надёжный вариант, ведь это занимает минуты на обычном ноутбуке.

Возможные проблемы прииндексации документов

PDF содержит только изображения. Если документ представляет собой скан, а не текст, библиотека PyPDF2 не сможет извлечь из него слова — она работает только с текстовым слоем. Для таких файлов нужно предварительное распознавание через OCR, например, с помощью Tesseract. Это выходит за рамки нашей книги, но вы можете добавить поддержку сканов позже, установив pytesseract и модифицировав функцию read_pdf.

Очень большой PDF на сотни страниц. Загрузка всего документа в память целиком может привести к её исчерпанию. Решение — читать PDF постранично с помощью PyPDF2.PdfReader и обрабатывать страницы порциями, а не все сразу. Наш код уже делает это: мы проходим по страницам в цикле, извлекая текст по одной, что экономит память.

Ошибка кодировки в текстовом файле. Не все TXT-файлы сохранены в UTF-8. В России до сих пор распространена кодировка Windows-1251, особенно в старых документах. В функции read_txt мы предусмотрели fallback: если чтение в UTF-8 не удалось, пробуем cp1251. Если вы работаете с файлами в других кодировках, можно добавить библиотеку chardet для автоматического определения кодировки.

Разрыв слов при разбивке на чанки. RecursiveCharacterTextSplitter старается разрезать текст по естественным границам — абзацам, предложениям, пробелам. Но с кириллицей он иногда ошибается, и слово может быть разорвано на границе чанка. Чтобы уменьшить вероятность разрывов, увеличьте параметр chunk_overlap — например, со 150 до 250 символов. Это создаст более широкую зону перекрытия между соседними чанками, и мысль не потеряется на стыке.

Что дальше

Мы подготовили базу знаний. Теперь осталось связать поиск по документам с нашей языковой моделью. В главе 4.5 «Реализация функции "Спросить по документам" с цитированием» мы напишем метод ask_documents(), который примет вопрос, найдёт релевантные чанки, вставит их в промпт и вернёт ответ со ссылками на источники. Это станет «вишенкой» нашего локального RAG.

Глава 4.5. Реализацияфункции «Спросить по документам» с цитированием

Мы подошли к моменту, когда разрозненные детали — embedding-модель, векторная база данных, индексатор — складываются в работающий механизм. В этой главе мы напишем функцию ask_documents(), которая примет вопрос, найдёт релевантные фрагменты в вашей личной коллекции документов, передаст их языковой модели и вернёт осмысленный ответ с указанием источников.

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

Архитектура: что происходит внутри ask_documents()

Функция выполняет четыре шага:

1. Векторизация вопроса. Мы берём вопрос пользователя и прогоняем через ту же embedding-модель, что использовали для индексации документов.

2. Поиск в ChromaDB. Векторная база возвращает, скажем, 4–5 самых близких по смыслу чанков.

3. Сборка промпта. Мы формируем системное сообщение, вставляем найденные чанки с указанием источников и задаём вопрос пользователя.

4. Генерация ответа. Модель получает промпт и генерирует ответ, содержащий факты из документов и ссылки на источники.

Всё это происходит локально, в одном процессе Python, без единого внешнего запроса.

Системный промпт для RAG

Правильно составленный системный промпт — ключ к качественному ответу. Он должен заставить модель:

· Опираться только на предоставленные документы.

· Честно признаваться, если ответа в них нет.

· Цитировать источники.

Вот промпт, который мы будем использовать:

text

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

Правила:

1. Если ответ можно найти в документах, дай его чётко и по делу.

2. После каждого факта указывай в скобках источник в формате (файл: имя_файла).

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

4. Если в документах нет ответа на вопрос, скажи: "В предоставленных документах информация не найдена."

5. Не придумывай ничего, чего нет в документах.

Код : метод ask_documents в классе LocalModel

Добавим новый метод в наш класс LocalModel. Откройте local_model.py и дополните его:

python

# local_model.py (дополнение к существующему классу)

from vector_store import VectorStore

from embedder import Embedder

class LocalModel:

# ... (предыдущие методы остаются без изменений) ...

def ask_documents(

self,

question: str,

store: VectorStore,

embedder: Embedder,

n_results: int = 4,

temperature: float = 0.3,

max_tokens: int = 512

) -> str:

"""

Задаёт вопрос по проиндексированным документам.

:param question: вопрос пользователя

:param store: объект VectorStore с загруженными документами

:param embedder: объект Embedder для векторизации вопроса

:param n_results: сколько релевантных чанков извлечь из базы

:param temperature: креативность ответа (низкая — строже по документам)

:param max_tokens: максимальная длина ответа

:return: ответ модели со ссылками на источники

"""

# Шаг 1: векторизуем вопрос

query_embedding = embedder.embed_query(question)

# Шаг 2: ищем релевантные чанки

results = store.search(query_embedding, n_results=n_results)

if not results["documents"] or not results["documents"][0]:

return "В предоставленных документах информация не найдена."

# Шаг 3: собираем фрагменты в промпт

context_parts = []

for doc, meta in zip(results["documents"][0], results["metadatas"][0]):

file_name = meta.get("file_name", "неизвестный файл")

context_parts.append(f"--- Фрагмент из файла: {file_name} ---\n{doc}\n")

context = "\n".join(context_parts)

system_prompt = (

"Ты — эксперт-аналитик, который отвечает на вопросы, основываясь исключительно на "

"предоставленных фрагментах документов. Правила:\n"

"1. Если ответ можно найти в документах, дай его чётко и по делу.\n"

"2. После каждого факта указывай в скобках источник в формате (файл: имя_файла).\n"

"3. Если информация из разных фрагментов противоречит друг другу, укажи на противоречие.\n"

"4. Если в документах нет ответа на вопрос, скажи: \"В предоставленных документах информация не найдена.\"\n"

"5. Не придумывай ничего, чего нет в документах."

)

user_message = (

f"Вопрос пользователя: {question}\n\n"

f"Фрагменты документов для анализа:\n\n{context}\n"

)

# Шаг 4: генерируем ответ

response = self.chat(

messages=[{"role": "user", "content": user_message}],

system_prompt=system_prompt,

temperature=temperature,

max_tokens=max_tokens

)

return response

Обратите внимание:

· temperature=0.3 — низкая температура делает модель более «послушной» и менее склонной к фантазиям. Для фактологического поиска это важно.

· Мы формируем промпт так, чтобы модель не получала голый вопрос, а видела перед собой задание аналитика с конкретными данными.

Полный тестовый сценарий

Создадим скрипт test_rag.py, который демонстрирует весь пайплайн: от индексации до вопросов.

python

# test_rag.py

from model_loader import download_model

from local_model import LocalModel

from embedder import Embedder

from vector_store import VectorStore

from indexer import index_folder

# 1. Подготовка: загружаем модель, embedding и хранилище

print("Загрузка языковой модели...")

model_path = download_model()

llm = LocalModel(model_path=model_path, n_threads=4, n_gpu_layers=0)

print("Загрузка embedding-модели...")

emb = Embedder()

print("Подготовка векторного хранилища...")

store = VectorStore(collection_name="my_docs")

# 2. Индексация (выполняется один раз или после добавления файлов)

# Если коллекция уже существует — очистим для чистоты эксперимента

store.clear()

count = index_folder(

folder_path="./sample_docs",

store=store,

embedder=emb,

chunk_size=500,

chunk_overlap=50

)

print(f"Проиндексировано чанков: {count}\n")

# 3. Задаём вопросы

questions = [

"Какие гарантийные обязательства у исполнителя?",

"В каких случаях договор может быть расторгнут досрочно?",

"Какой размер штрафа за просрочку оплаты?",

"Есть ли в документах упоминание о конфиденциальности?"

]

for q in questions:

print(f"\n{'='*60}")

print(f"Вопрос: {q}")

print(f"{'='*60}")

answer = llm.ask_documents(

question=q,

store=store,

embedder=emb,

n_results=3,

temperature=0.2

)

print(f"Ответ: {answer}")

Запустим:

bash

python test_rag.py

Пример вывода:

text

Загрузка языковой модели...

Модель загружена.

Загрузка embedding-модели...

Модель загружена, размерность векторов: 768

Подготовка векторного хранилища...

Обработка: ./sample_docs/contract.txt

-> Получено чанков: 4

Проиндексировано чанков: 4

============================================================

Вопрос: Какие гарантийные обязательства у исполнителя?

============================================================

Ответ: Согласно контракту (файл: contract.txt), исполнитель гарантирует устранение

недостатков в течение 30 календарных дней с момента получения уведомления.

Гарантийный срок на результаты работ составляет 12 месяцев (файл: contract.txt).

============================================================

Вопрос: В каких случаях договор может быть расторгнут досрочно?

============================================================

Ответ: Досрочное расторжение возможно при существенном нарушении условий одной из

сторон (файл: contract.txt). Также договор может быть расторгнут по взаимному

соглашению сторон с письменным уведомлением за 30 дней (файл: contract.txt).

============================================================

Вопрос: Какой размер штрафа за просрочку оплаты?

============================================================

Ответ: За каждый день просрочки оплаты начисляется пеня в размере 0.1% от

неоплаченной суммы (файл: contract.txt).

============================================================

Вопрос: Есть ли в документах упоминание о конфиденциальности?

============================================================

Ответ: В предоставленных документах информация не найдена.

Модель корректно извлекла факты и честно призналась, когда информации нет. Это именно то поведение, которое нам нужно.

Потоковая версия метода

Для будущего GUI нам понадобится потоковая версия ask_documents(), чтобы ответ появлялся на экране постепенно, как в чате. Добавим её:

python

def ask_documents_stream(

self,

question: str,

store: VectorStore,

embedder: Embedder,

n_results: int = 4,

temperature: float = 0.3,

max_tokens: int = 512

):

"""Потоковая версия ask_documents()."""

query_embedding = embedder.embed_query(question)

results = store.search(query_embedding, n_results=n_results)


if not results["documents"] or not results["documents"][0]:

yield "В предоставленных документах информация не найдена."

return

context_parts = []

for doc, meta in zip(results["documents"][0], results["metadatas"][0]):

file_name = meta.get("file_name", "неизвестный файл")

context_parts.append(f"--- Фрагмент из файла: {file_name} ---\n{doc}\n")

context = "\n".join(context_parts)

system_prompt = (

"Ты — эксперт-аналитик... Правила: ..." # тот же промпт

)

user_message = f"Вопрос пользователя: {question}\n\nФрагменты документов:\n\n{context}"

На страницу:
7 из 9