Представьте: у вас 10 000 внутренних документов компании — регламенты, договоры, техническая документация. Сотрудники тратят часы, пытаясь найти нужный пункт. Поиск по ключевым словам не работает — люди спрашивают «как оформить командировку», а документ называется «Положение о служебных поездках».

RAG (Retrieval-Augmented Generation) решает именно эту проблему. Система понимает смысл вопроса, находит релевантные фрагменты из базы знаний и генерирует точный ответ с указанием источника. В этом руководстве мы построим такую систему с нуля — с реальным кодом, PostgreSQL, pgvector и LangChain.

Что такое RAG и почему pgvector

RAG — это архитектурный паттерн, при котором языковая модель не «придумывает» ответ из обучающих данных, а сначала извлекает релевантные документы из внешней базы знаний, а затем генерирует ответ на их основе. Это решает главную проблему LLM — галлюцинации и устаревшие знания.


graph LR
    A[Вопрос пользователя] --> B[Модель эмбеддингов]
    B --> C[Векторный запрос]
    C --> D[(pgvector / PostgreSQL)]
    D --> E[Топ-K документов]
    E --> F[Контекст + вопрос]
    F --> G[LLM Claude / GPT]
    G --> H[Ответ с источниками]

Почему pgvector, а не специализированная векторная БД?

Многие команды уже используют PostgreSQL. Добавить расширение pgvector проще, чем поднимать и поддерживать отдельный Pinecone, Weaviate или Qdrant. Для большинства проектов с объёмом до нескольких миллионов документов производительности pgvector вполне достаточно.

ПараметрpgvectorPineconeWeaviate
ИнфраструктураСуществующий PostgreSQLManaged SaaSSelf-hosted / Cloud
Стоимость при 1M векторов~$0 (сервер уже есть)от $70/месот $25/мес
SQL + векторы в одном запросеДаНетНет
Масштаб (макс. рекомендуемый)~10M векторовМиллиардыМиллиарды
ТранзакционностьACIDНетНет
ℹ Актуальная версия пакета
В 2024 году интеграция pgvector переехала из langchain-community в отдельный пакет langchain-postgres. С версии 0.0.14 устаревший класс PGVector заменён на PGVectorStore. В этом руководстве используется актуальный API.

Установка и подготовка окружения

Зависимости

pip install langchain langchain-postgres langchain-openai \
            psycopg[binary] python-dotenv pypdf

Для PostgreSQL с pgvector проще всего использовать Docker:

docker run -d \
  --name pgvector-db \
  -e POSTGRES_USER=rag_user \
  -e POSTGRES_PASSWORD=secret \
  -e POSTGRES_DB=rag_db \
  -p 5432:5432 \
  pgvector/pgvector:pg16

Официальный образ pgvector/pgvector идёт с уже установленным расширением. Если используете существующий PostgreSQL, подключите расширение вручную:

CREATE EXTENSION IF NOT EXISTS vector;

Структура проекта

rag-system/
├── .env
├── ingest.py        # Загрузка и индексация документов
├── query.py         # RAG-запросы
└── documents/       # PDF, TXT, MD-файлы

Файл .env:

OPENAI_API_KEY=sk-...
DATABASE_URL=postgresql+psycopg://rag_user:secret@localhost:5432/rag_db

Индексация документов

Загрузка и разбивка текста

# ingest.py
import os
from pathlib import Path
from dotenv import load_dotenv

from langchain_community.document_loaders import PyPDFLoader, TextLoader
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_openai import OpenAIEmbeddings
from langchain_postgres import PGEngine, PGVectorStore

load_dotenv()

# Настройка движка БД
engine = PGEngine.from_connection_string(url=os.environ["DATABASE_URL"])

# Инициализация таблицы (создаётся один раз)
engine.init_vectorstore_table(
    table_name="documents",
    vector_size=1536,  # размер для text-embedding-3-small
)

# Модель эмбеддингов
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")

# Создание векторного хранилища
store = PGVectorStore.create_sync(
    engine=engine,
    table_name="documents",
    embedding_service=embeddings,
)

def load_documents(folder: str):
    docs = []
    for path in Path(folder).rglob("*"):
        if path.suffix == ".pdf":
            loader = PyPDFLoader(str(path))
        elif path.suffix in (".txt", ".md"):
            loader = TextLoader(str(path), encoding="utf-8")
        else:
            continue
        docs.extend(loader.load())
    return docs

def ingest(folder: str = "documents"):
    raw_docs = load_documents(folder)
    print(f"Загружено документов: {len(raw_docs)}")

    splitter = RecursiveCharacterTextSplitter(
        chunk_size=1000,
        chunk_overlap=200,
        separators=["\n\n", "\n", ".", " "],
    )
    chunks = splitter.split_documents(raw_docs)
    print(f"Чанков для индексации: {len(chunks)}")

    store.add_documents(chunks)
    print("Индексация завершена.")

if __name__ == "__main__":
    ingest()
💡 Размер чанка имеет значение
chunk_size=1000 с chunk_overlap=200 — хорошая отправная точка. Маленькие чанки (300–500 символов) дают точность, но теряют контекст. Большие (2000+) дают контекст, но снижают точность поиска. Для технических документов с формулами попробуйте chunk_size=500.

Создание индекса HNSW

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

-- HNSW-индекс для косинусного сходства
CREATE INDEX ON documents
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);

Параметры HNSW:

  • m — количество связей на узел графа (16 — стандарт, больше = точнее, но медленнее сборка)
  • ef_construction — размер динамического списка при построении (64–128 — разумный диапазон)

HNSW обеспечивает в 15× большую пропускную способность по сравнению с IVFFlat при одинаковом уровне полноты (recall 0.998). Для RAG-систем HNSW — выбор по умолчанию.

Сравнение индексов: HNSW vs IVFFlat

Выбор типа индекса напрямую влияет на производительность системы.

ХарактеристикаHNSWIVFFlat
Скорость поиска (QPS при recall 0.998)40.52.6
Время построения индекса4065 сек128 сек
Память (на 1M векторов)729 МБ257 МБ
Требует предварительной кластеризацииНетДа (нужны данные заранее)
Поддержка инкрементальных вставокХорошаяДеградирует со временем
Настройка2 параметра2 параметра
⚠ Когда IVFFlat оправдан
IVFFlat имеет смысл только если у вас статичная база знаний (документы не обновляются), объём превышает 5M векторов, и память критична. В большинстве RAG-проектов используйте HNSW — он проще в эксплуатации и быстрее.

RAG-запросы: сборка цепочки

# query.py
import os
from dotenv import load_dotenv
from langchain_openai import OpenAIEmbeddings, ChatOpenAI
from langchain_postgres import PGEngine, PGVectorStore
from langchain.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnablePassthrough

load_dotenv()

engine = PGEngine.from_connection_string(url=os.environ["DATABASE_URL"])
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")

store = PGVectorStore.create_sync(
    engine=engine,
    table_name="documents",
    embedding_service=embeddings,
)

# Ретривер: топ-5 релевантных чанков
retriever = store.as_retriever(
    search_type="similarity",
    search_kwargs={"k": 5},
)

# Промпт с контекстом
PROMPT_TEMPLATE = """Ты — помощник по работе с документами компании.
Отвечай на вопрос ТОЛЬКО на основе предоставленного контекста.
Если информации недостаточно — так и скажи, не придумывай.

Контекст:
{context}

Вопрос: {question}

Ответ:"""

prompt = ChatPromptTemplate.from_template(PROMPT_TEMPLATE)
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)

def format_docs(docs):
    return "\n\n---\n\n".join(
        f"[Источник: {d.metadata.get('source', 'неизвестно')}]\n{d.page_content}"
        for d in docs
    )

# LCEL-цепочка
rag_chain = (
    {"context": retriever | format_docs, "question": RunnablePassthrough()}
    | prompt
    | llm
    | StrOutputParser()
)

def ask(question: str) -> str:
    return rag_chain.invoke(question)

if __name__ == "__main__":
    answer = ask("Как оформить заявление на отпуск?")
    print(answer)
📝 Пример вывода

Вопрос: Как оформить заявление на отпуск?

Ответ: Согласно Положению о предоставлении отпусков (раздел 3.2), заявление подаётся не позднее чем за 14 дней до начала отпуска через систему HR-портал или в бумажном виде на имя непосредственного руководителя. [Источник: hr_policy_2024.pdf]

Продвинутые техники

Гибридный поиск: семантика + ключевые слова

Чистый семантический поиск иногда упускает документы с точными совпадениями (артикулы, имена, коды). Решение — гибридный поиск через tsvector PostgreSQL:

-- Добавляем полнотекстовый поиск к таблице
ALTER TABLE documents ADD COLUMN fts_content tsvector
    GENERATED ALWAYS AS (to_tsvector('russian', cmetadata->>'content')) STORED;

CREATE INDEX ON documents USING gin(fts_content);

Затем комбинируем результаты через RRF (Reciprocal Rank Fusion) — суммируем ранги из семантического и полнотекстового поиска.

Фильтрация по метаданным

pgvector поддерживает фильтрацию до векторного поиска:

# Искать только в документах отдела HR
results = store.similarity_search(
    query="отпуск",
    k=5,
    filter={"department": "HR", "year": 2024},
)

Мониторинг качества

Отслеживайте две ключевые метрики:

МетрикаЧто измеряетКак считать
Recall@KДоля релевантных документов в топ-KТестовая выборка с размеченными ответами
FaithfulnessСоответствие ответа контекстуLLM-as-judge или RAGAs framework
pip install ragas
from ragas import evaluate
from ragas.metrics import faithfulness, answer_relevancy, context_recall

# Запускаем оценку на тестовом датасете
results = evaluate(dataset, metrics=[faithfulness, answer_relevancy, context_recall])
print(results)

Заключение

Мы построили полноценную RAG-систему: от загрузки документов через LangChain до поиска по векторному индексу в PostgreSQL и генерации ответов с LLM.

Ключевые выводы:

  • langchain-postgres с PGVectorStore — актуальный API (старый PGVector из langchain-community устарел)
  • HNSW-индекс — выбор по умолчанию для большинства RAG-систем: быстрее, не требует перестройки при добавлении данных
  • Размер чанка (1000 символов) и перекрытие (200 символов) — отправная точка, подбирайте под свою задачу
  • Гибридный поиск (семантика + FTS) улучшает recall на запросах с конкретными терминами
  • Измеряйте качество через RAGAs, а не только субъективную оценку

PostgreSQL с pgvector — прагматичный выбор для команд, которые хотят RAG без дополнительной инфраструктуры. Если ваш объём вырастет до десятков миллионов векторов — рассмотрите миграцию на специализированные решения. До этого момента pgvector справится.