Ollama RAG: локальная приватная база знаний для вопросов и ответов
В этой главе мы объединяем изученные ранее возможности эмбеддинга, поиска и генерации в действительно полезное приложение: подайте свои документы локальной модели и позвольте ей отвечать на вопросы, основываясь только на ваших материалах, с указанием источников.
Полный код занимает менее 100 строк, не имеет внешних зависимостей от векторных баз данных и может быть запущен простым копированием.
Разбор требований и выбор технологий
Сначала уточним проблему: LLM не знает материалы на вашем жёстком диске, и если спросить его напрямую, он выдаст только выдуманные ответы. Идея RAG (Retrieval-Augmented Generation) — это «экзамен с открытой книгой»: сначала извлеките релевантные фрагменты из ваших материалов, вставьте их в промпт и позвольте модели ответить на их основе.
Выбор технологий следует принципу «минимальных зависимостей»:
| Этап | Выбором | Причина |
|---|---|---|
| Модель эмбеддингов | embeddinggemma | Малый размер, быстро работает локально; альтернатива: qwen3-embedding |
| Модель генерации | qwen3.5:4b | Хорошо справляется с китайским, может работать на 16GB RAM |
| Векторное хранилище | Список Python + косинусное сходство | Ноль зависимостей, наглядно для обучения; для больших объёмов переходите на профессиональные библиотеки вроде Chroma |
| Формат документа | txt / md | Обычного текста достаточно, чтобы объяснить принципы; расширение PDF см. в конце статьи. |
Вам нужно загрузить две модели:
ollama pull embeddinggemma ollama pull qwen3.5:4b
EmbeddingGemma — это легковесная многоязычная модель текстовых эмбеддингов с открытым исходным кодом от Google, специально предназначенная для преобразования текста в векторы. Она отлично подходит для офлайн-развёртывания на локальных устройствах. Ollama уже адаптировала и упаковала эту модель, поэтому её можно сразу загрузить и использовать.
Шаг 1: Разбор документа и разбиение на фрагменты
Контекст модели ограничен, и весь документ не помещается в него, поэтому его нужно разбивать на небольшие фрагменты. Стратегия разбиения использует самый базовый подход «фиксированная длина + перекрытие»: каждый фрагмент — 300 символов, соседние фрагменты перекрываются на 50 символов, чтобы не обрезать ключевые предложения.
Пример
import os
def load_chunks(folder, size=300, overlap=50):
"""Читает все файлы txt/md в папке, разбивает их на фрагменты с маркерами источника"""
chunks = []
for name in sorted(os.listdir(folder)):
if not name.endswith(('.txt', '.md')):
continue
path = os.path.join(folder, name)
text = open(path, encoding='utf-8').read().strip()
# Разбиение скользящим окном: размер шага = size - overlap
step = size - overlap
for i in range(0, len(text), step):
piece = text[i:i + size].strip()
if len(piece) > 20: # Отфильтровать фрагменты, которые слишком короткие
chunks.append({'file': name, 'text': piece})
return chunks
Шаг 2: Векторизация и поиск
Преобразовать все фрагменты и вопрос пользователя в векторы и использовать косинусное сходство, чтобы найти top-k фрагментов, наиболее релевантных вопросу.
Пример
EMBED_MODEL = 'embeddinggemma'
def embed(texts):
"""Генерировать векторы пакетно (на входе список, на выходе список векторов)"""
result = ollama.embed(model=EMBED_MODEL, input=texts)
return result['embeddings']
def cosine(a, b):
"""Косинусное сходство: чем ближе к 1, тем более похожи"""
dot = sum(x * y for x, y in zip(a, b))
na = sum(x * x for x in a) ** 0.5
nb = sum(x * x for x in b) ** 0.5
return dot / (na * nb)
def search(question, chunks, vectors, top_k=3):
"""Поиск: вернуть k наиболее релевантных фрагментов и их сходства"""
q_vec = embed([question])[0]
scored = [
(cosine(q_vec, v), c)
for v, c in zip(vectors, chunks)
]
scored.sort(key=lambda x: -x[0])
return scored[:top_k]
embeddinggemma выводит L2-нормализованные векторы; полная формула косинуса всё ещё приведена здесь, чтобы принцип был понятен с первого взгляда; замена её на скалярное произведение даёт тот же результат.
Шаг 3: Сборка промпта и генерация ответов с цитатами
Запишите извлечённые фрагменты вместе с номерами источников в промпт и явно потребуйте от модели «отвечать только на основе материалов» и помечать цитаты.
Пример
GEN_MODEL = 'qwen3.5:4b'
def answer(question, chunks, vectors):
hits = search(question, chunks, vectors, top_k=3)
# Объединить фрагменты и номера источников в контекст
context = '\n\n'.join(
f'[Источник {idx}] (из {c["file"]})\n{c["text"]}'
for idx, (score, c) in enumerate(hits, 1)
)
prompt = f'''Ответьте на вопрос, основываясь только на следующих материалах, и укажите, какие номера источников были использованы.
Если материалов недостаточно для ответа, прямо скажите, что вы не знаете.
Материалы:
{context}
Вопрос: {question}'''
response = chat(
model=GEN_MODEL,
messages=[{'role': 'user', 'content': prompt}],
options={'temperature': 0.2}, # Низкая температура, снижает свободу творчества
)
print(response.message.content)
print('\nИсточники: ')
for idx, (score, c) in enumerate(hits, 1):
print(f' [Источник {idx}] {c["file"]} (сходство {score:.3f})')
Полный запуск: точка входа в основную программу
Пример
if __name__ == '__main__':
folder = 'docs' # Поместите ваши txt/md документы в папку docs
chunks = load_chunks(folder)
# Офлайн-индексация: векторизовать все чанки за один раз
print(f'Индексация {len(chunks)} чанков...')
vectors = embed([c['text'] for c in chunks])
print('Индексация завершена.'\n')
# Онлайн-цикл вопросов и ответов
while True:
q = input('Вопрос (введите exit для выхода): ').strip()
if q.lower() == 'exit':
break
if q:
ответ(q, фрагменты, векторы)
print()
Подготовьте материалы и запустите:
mkdir docs cp EXAMPLE-python-notes.md docs/ python rag.py
Вывод:
$ python rag.py 正在索引 42 个片段... 索引完成。 问题(exit 退出):Python 的切片是什么? Python 切片是用 [start:stop:step] 从序列截取子序列的语法 [资料1]。 例如 s[1:4] 取索引 1 到 3 的字符 [资料2]。 参考来源: [资料1] EXAMPLE-python-notes.md(相似度 0.712) [资料2] EXAMPLE-python-notes.md(相似度 0.668)
Обратите внимание на ссылки на источники в конце ответа: каждое предложение можно отследить до конкретного файла в вашем каталоге docs. Это как раз основная ценность RAG по сравнению с "прямым запросом к модели."
Настройка производительности и типичные проблемы
Если производительность RAG низкая, проблема обычно на этапе поиска, а не на этапе генерации. Рычаги настройки, упорядоченные по влиянию:
| Рычаг | Значение по умолчанию | Направление настройки |
|---|---|---|
| Размер фрагмента | 300 символов | Увеличьте, если ответы слишком фрагментированы; слишком большое значение делает поиск неточным; эмпирический диапазон 200~500 |
| Перекрытие | 50 символов | Увеличьте, когда предложения часто обрываются |
| Количество извлекаемых элементов top_k | 3 | Увеличивайте, когда материалы плотные, но это потребует больше контекста |
| Порог сходства | Не включено | Отфильтровывайте фрагменты с слишком низким сходством, чтобы уменьшить нерелевантные помехи |
| Температура генерации | 0.2 | Оставляйте её низкой; базе знаний не нужна креативность |
Краткая справка по типичным проблемам:
| Симптом | Причина | Решение |
|---|---|---|
| Ответ не связан с материалами | Поиск не дал результатов | Проверьте, не слишком ли малы/велики фрагменты, и убедитесь, что язык вопроса совпадает с языком материалов |
| Модель всё ещё выдумывает ответы | Недостаточно ограничений в промпте | Усильте формулировку «только на основе материалов»; когда ничего не найдено, отвечайте прямо «Я не знаю» |
| Индексация очень медленная | Слишком много чанков встраиваются по одному | Используйте пакетное встраивание; переходите на профессиональные векторные базы данных и инкрементальную индексацию, когда объём документов большой |
| Ответы ухудшаются для длинных документов | Ключевая информация фрагментирована | Разбивайте на чанки по семантическим границам, таким как заголовки/абзацы, вместо чисто фиксированной длины |
Другие расширенияДорожная карта для продакшена: когда у вас десятки тысяч документов или нужен парсинг PDF, замените «списочное векторное хранилище» на профессиональную векторную базу данных, такую как Chroma (которая обеспечивает персистентность и эффективный поиск ближайших соседей), используйте pypdf для извлечения текста из PDF, а остальной каркас кода оставьте без изменений — именно в этом смысл разделения каждого этапа в этой статье.