Трендовые github проекты в нашем телеграм канале. Подпишись → Локальный ассистент по документации проекта на базе retrieval-пайплайна
Любая языковая модель общего назначения отвечает шаблонами из чужих репозиториев и не знает, что в вашем проекте авторизация завязана на Redis, а конфигурация базы данных читается из переменных окружения с собственным префиксом. Причина не в качестве модели — её знания заканчиваются в момент обучения, а документация конкретного проекта живёт отдельно и туда не попадает. Retrieval-Augmented Generation (RAG) закрывает этот разрыв: вместо того чтобы модель «помнила» всё, ей на каждый запрос подкладывают нужные фрагменты документов прямо в контекст.
Ниже — рабочая схема такого пайплайна, которую можно поднять на домашнем сервере поверх собственной базы знаний, вики или каталога markdown-файлов из репозитория.
Из чего состоит retrieval-пайплайн
В типичном RAG четыре шага:
- Пользователь присылает вопрос.
- Вопрос превращается в вектор — числовое представление смысла текста.
- По этому вектору в векторной базе ищутся документы с ближайшими векторами.
- Найденные фрагменты подставляются в промпт и уходят в LLM вместе с вопросом.
Модель отвечает, опираясь на переданный контекст, а не на то, что запомнила при обучении. За счёт этого ответ становится актуальным и привязанным именно к вашему проекту.
Эмбеддинги: как компьютер сопоставляет смысл, а не слова
В основе поиска лежат эмбеддинги — векторы, представляющие смысл текста. Два предложения с одинаковым смыслом получают близкие векторы, даже если в них нет общих слов. Классический пример: «руководство по диагностике двигателя» и «поиск причины перебоев в работе мотора» — разные слова, но об одном и том же. Обычный поиск по ключевым словам их не свяжет, а эмбеддинги — свяжут, потому что векторы окажутся рядом в многомерном пространстве.
На практике для генерации эмбеддингов достаточно небольшой открытой модели, которая свободно работает на CPU домашнего сервера:
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("all-MiniLM-L6-v2")
documents = [
"A beginner's guide to engine troubleshooting",
"Motor diagnostics for intermittent power loss",
]
embeddings = model.encode(documents)
Первые два вектора в embeddings окажутся близки по косинусному расстоянию.
Благодаря этому свойству, когда пользователь спрашивает «как настроить подключение к БД», система найдёт документ про «конфигурацию пула соединений», даже если точных текстовых совпадений нет.
Шаг 1. Разбиение документов на чанки
Документация редко помещается в контекст модели целиком, поэтому текст режут на смысловые куски по несколько сотен слов с небольшим перекрытием — это не даёт терять смысл на границах фрагментов:
def chunk_text(text, chunk_size=500, overlap=50):
words = text.split()
chunks = []
for i in range(0, len(words), chunk_size - overlap):
chunk = " ".join(words[i:i + chunk_size])
chunks.append(chunk)
return chunks
Перекрытие важно именно потому, что смежные абзацы часто ссылаются друг на друга: определение термина может быть в конце одного чанка, а его использование — в начале следующего.
Шаг 2. Векторный индекс на ChromaDB
Для локальной разработки и небольших баз знаний хорошо подходит ChromaDB — она поднимается без отдельного сервера и хранит и эмбеддинги, и сами тексты:
import chromadb
from sentence_transformers import SentenceTransformer
client = chromadb.Client()
collection = client.create_collection("docs")
model = SentenceTransformer("all-MiniLM-L6-v2")
for i, chunk in enumerate(chunks):
embedding = model.encode(chunk).tolist()
collection.add(
ids=[str(i)],
embeddings=[embedding],
documents=[chunk],
)
Для больших объёмов документации на homelab-сервере логичная замена — FAISS с сохранением индекса на диск, чтобы не пересчитывать эмбеддинги при каждом перезапуске сервиса.
Шаг 3. Поиск релевантных фрагментов
Для входящего вопроса считается эмбеддинг, а затем в индексе ищутся ближайшие по смыслу чанки:
def retrieve(query, top_k=3):
query_embedding = model.encode(query).tolist()
results = collection.query(
query_embeddings=[query_embedding],
n_results=top_k,
)
return results["documents"][0]
Количество возвращаемых фрагментов (top_k) напрямую влияет на качество ответа: слишком мало — модель не увидит нужный контекст, слишком много — в промпте появится шум, который снижает точность ответа.
Шаг 4. Генерация ответа с указанием источников
Найденные фрагменты подставляются в промпт, и модель отвечает строго на их основе:
from openai import OpenAI
client = OpenAI()
def ask(query):
docs = retrieve(query)
context = "\n\n".join(docs)
prompt = f"""
Ответь на вопрос, используя только информацию из предоставленных документов.
Если в документах нет ответа, скажи об этом прямо.
Документы:
{context}
Вопрос: {query}
"""
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt}],
)
return response.choices[0].message.content
Явное указание «отвечай только на основе документов» в промпте — не формальность, а рабочий способ снизить количество выдуманных моделью фактов.
Собираем ассистента по документации репозитория
Дальше эти шаги удобно объединить в класс, который умеет добавлять файлы документации и запоминать, из какого файла взят каждый фрагмент — это даёт возможность прикладывать источники к ответу:
class ProjectRAG:
def __init__(self, api_key):
self.embedder = SentenceTransformer("all-MiniLM-L6-v2")
self.client = chromadb.Client()
self.collection = self.client.create_collection("project_docs")
self.llm = OpenAI(api_key=api_key)
def add_document(self, file_path):
with open(file_path, "r") as f:
text = f.read()
chunks = self._chunk_text(text)
embeddings = self.embedder.encode(chunks)
self.collection.add(
ids=[f"{file_path}_{i}" for i in range(len(chunks))],
embeddings=embeddings.tolist(),
documents=chunks,
metadatas=[{"source": file_path} for _ in chunks],
)
def ask(self, query):
q_emb = self.embedder.encode([query]).tolist()
results = self.collection.query(
query_embeddings=q_emb,
n_results=3,
)
context = "\n\n".join(results["documents"][0])
sources = set(r["source"] for r in results["metadatas"][0])
prompt = f"""
Ты — ассистент по проекту. Отвечай, используя только документы ниже.
Если ответа нет в документах, скажи "В документации нет информации".
В конце ответа укажи источники.
Документы:
{context}
Вопрос: {query}
"""
response = self.llm.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt}],
)
return {
"answer": response.choices[0].message.content,
"sources": sources,
}
Такой класс достаточно один раз проиндексировать всеми markdown-файлами из репозитория, чтобы получить чат, который отвечает на вопросы по конкретному проекту, а не по общим представлениям о том, как «обычно» устроены такие системы.
Гибридный поиск как следующий шаг
Чистый семантический поиск не всегда выигрывает у поиска по ключевым словам: если пользователь ищет конкретное имя переменной окружения или название функции, точное совпадение может оказаться важнее смыслового сходства. Поэтому в продуктивных системах используют гибридный поиск — комбинацию классического ключевого поиска (BM25) и семантического поиска с последующим переранжированием результатов более мощной моделью (кросс-энкодером). Такая связка не пропускает ни точные совпадения, ни смысловые аналоги и заметно повышает точность выдачи на больших базах документации.
Что в итоге определяет качество RAG
RAG не делает модель умнее — он даёт ей доступ к нужным данным в нужный момент. Качество итоговой системы держится на нескольких настройках:
- размер чанка и величина перекрытия при разбиении текста;
- выбор модели эмбеддингов под конкретный язык и предметную область;
- количество фрагментов, передаваемых в контекст на один запрос;
- формулировка промпта, которая ограничивает модель только переданным контекстом.
При аккуратной настройке этих параметров RAG превращает обычную LLM в ассистента, разбирающегося именно в вашей документации и инфраструктуре, — и для этого не требуется дообучать модель, а поднять весь пайплайн можно на одном сервере домашней лаборатории.