Практика: чат-бот по своим документам

Урок собирает изученное в один работающий Q&A-бот по вашим документам.

Q&A over docs — приложение, которое отвечает на вопросы пользователя, опираясь на загруженную базу собственных документов через RAG.

План сборки

Бот по документам — это конвейер из всех изученных блоков. Соберём его по шагам:

1. Загрузить документы      => DocumentLoader
2. Нарезать на чанки         => TextSplitter
3. Превратить в векторы      => Embeddings
4. Сложить в индекс          => VectorStore
5. Сделать retriever         => store.as_retriever()
6. Собрать RAG-цепочку        => context + question => prompt => model
7. (опц.) добавить память     => RunnableWithMessageHistory

Индексация (один раз)

from langchain_community.document_loaders import TextLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_openai import OpenAIEmbeddings
from langchain_community.vectorstores import FAISS

docs = TextLoader("handbook.txt").load()
chunks = RecursiveCharacterTextSplitter(
    chunk_size=500, chunk_overlap=50).split_documents(docs)
store = FAISS.from_documents(chunks, OpenAIEmbeddings(
    model="text-embedding-3-small"))
store.save_local("faiss_index")   # чтобы не индексировать заново

Цепочка ответа (на каждый вопрос)

from langchain_core.runnables import RunnableParallel, RunnablePassthrough
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_openai import ChatOpenAI

retriever = store.as_retriever(search_kwargs={"k": 4})
prompt = ChatPromptTemplate.from_template(
    "Ты помощник по внутренней документации. Отвечай только по контексту, "
    "если ответа нет — скажи 'не знаю'.\nКонтекст:\n{context}\n\nВопрос: {question}"
)
qa = (RunnableParallel(context=retriever, question=RunnablePassthrough())
      | prompt | ChatOpenAI(model="gpt-4o-mini") | StrOutputParser())
print(qa.invoke("Какой регламент отпусков?"))

Как работает под капотом

Ключевая идея — разделить дорогую индексацию и дешёвый ответ. Индексацию (шаги 1–4) делают один раз и сохраняют индекс на диск: повторно гонять эмбеддинги по всем документам на каждый вопрос — это лишние деньги и время. На запросе работает только лёгкая часть: retriever достаёт несколько чанков, промпт заземляет на них модель, парсер отдаёт строку. Память (если нужна диалоговость) оборачивает эту цепочку и подкладывает историю по session_id — как в разделе про память.

Частые ошибки

  • Переиндексировать на каждый запрос. Сохраняйте индекс (save_local) и грузите готовый.
  • Нет ветки «не знаю». Без неё бот фантазирует, когда в документах нет ответа.
  • Не показывать источники. Для доверия выводите, из каких документов взят ответ (метаданные чанков).

Итог

  • Q&A-бот = загрузка → чанки → эмбеддинги → индекс → retriever → RAG-цепочка (+ память).
  • Индексация делается один раз и сохраняется; ответ использует лёгкую часть.
  • Обязательны заземление «только по контексту» и ветка «не знаю».
  • Показ источников повышает доверие к ответам.
Проверьте себя
1. Почему индексацию документов выносят в отдельный одноразовый шаг?
AТак требует LangChain
BЭмбеддинги по всем документам дороги и медленны — индекс считают раз и сохраняют
CИначе retriever не работает
DЧтобы спрятать данные от модели
2. Зачем в Q&A-боте предусматривать ответ «не знаю»?
AЧтобы сэкономить токены всегда
BЧтобы бот не фантазировал, когда в документах нет ответа
CЧтобы ускорить эмбеддинги
DЭто требование лицензии