Ollama RAG: локальная приватная база знаний для вопросов и ответов

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

Полный код занимает менее 100 строк, не имеет внешних зависимостей от векторных баз данных и может быть запущен простым копированием.


Разбор требований и выбор технологий

Сначала уточним проблему: LLM не знает материалы на вашем жёстком диске, и если спросить его напрямую, он выдаст только выдуманные ответы. Идея RAG (Retrieval-Augmented Generation) — это «экзамен с открытой книгой»: сначала извлеките релевантные фрагменты из ваших материалов, вставьте их в промпт и позвольте модели ответить на их основе.

RAG 双阶段流程:离线索引与在线问答

Выбор технологий следует принципу «минимальных зависимостей»:

ЭтапВыборомПричина
Модель эмбеддингов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 символов, чтобы не обрезать ключевые предложения.

Пример

# Путь к файлу: rag.py
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 фрагментов, наиболее релевантных вопросу.

Пример

import ollama

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: Сборка промпта и генерация ответов с цитатами

Запишите извлечённые фрагменты вместе с номерами источников в промпт и явно потребуйте от модели «отвечать только на основе материалов» и помечать цитаты.

Пример

from ollama import chat

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})')

Полный запуск: точка входа в основную программу

Пример

# Добавьте в конец rag.py
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_k3Увеличивайте, когда материалы плотные, но это потребует больше контекста
Порог сходстваНе включеноОтфильтровывайте фрагменты с слишком низким сходством, чтобы уменьшить нерелевантные помехи
Температура генерации0.2Оставляйте её низкой; базе знаний не нужна креативность

Краткая справка по типичным проблемам:

СимптомПричинаРешение
Ответ не связан с материаламиПоиск не дал результатовПроверьте, не слишком ли малы/велики фрагменты, и убедитесь, что язык вопроса совпадает с языком материалов
Модель всё ещё выдумывает ответыНедостаточно ограничений в промптеУсильте формулировку «только на основе материалов»; когда ничего не найдено, отвечайте прямо «Я не знаю»
Индексация очень медленнаяСлишком много чанков встраиваются по одномуИспользуйте пакетное встраивание; переходите на профессиональные векторные базы данных и инкрементальную индексацию, когда объём документов большой
Ответы ухудшаются для длинных документовКлючевая информация фрагментированаРазбивайте на чанки по семантическим границам, таким как заголовки/абзацы, вместо чисто фиксированной длины

Дорожная карта для продакшена: когда у вас десятки тысяч документов или нужен парсинг PDF, замените «списочное векторное хранилище» на профессиональную векторную базу данных, такую как Chroma (которая обеспечивает персистентность и эффективный поиск ближайших соседей), используйте pypdf для извлечения текста из PDF, а остальной каркас кода оставьте без изменений — именно в этом смысл разделения каждого этапа в этой статье.

Другие расширения