Команда Python for Devs подготовила практическое руководство по сборке полноценной RAG-системы из пяти open source-инструментов. MarkItDown, LangChain, ChromaDB, Ollama и Gradio превращают разрозненные документы в умную базу знаний с потоковой генерацией ответов. Всё локально, без облаков и с открытым кодом — попробуйте собрать свой ChatGPT прямо у себя.


Бывало, вы тратили по полчаса, просматривая ветки Slack, вложения к письмам и общие диски, лишь чтобы найти ту самую техническую спецификацию, о которой коллега упоминал на прошлой неделе?

Это типичный сценарий, который ежедневно повторяется в компаниях по всему миру. Специалисты тратят ценное время на поиск информации, которая должна быть доступна мгновенно, и в итоге теряют продуктивность.

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

В этой статье мы построим полноценный RAG-пайплайн, который превратит коллекции документов в систему ответов на вопросы на базе ИИ.

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

Вот чему вы научитесь:

  • Конвертировать документы с MarkItDown за 3 строки

  • Умно разбивать текст с помощью LangChain RecursiveCharacterTextSplitter

  • Генерировать эмбеддинги локально с моделью SentenceTransformers

  • Хранить векторы в персистентной базе ChromaDB

  • Генерировать ответы с локальными LLM в Ollama

  • Развернуть веб-интерфейс с потоковой передачей (streaming) в Gradio

Получить код: полный исходник и Jupyter-ноутбук для этого руководства доступны на GitHub. Клонируйте репозиторий, чтобы идти по шагам!

Введение в RAG-системы

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

Настройка окружения

Установите необходимые библиотеки для сборки вашего RAG-пайплайна:

pip install markitdown[pdf] sentence-transformers langchain-text-splitters chromadb gradio langchain-ollama ollama

Эти библиотеки дают следующее:

  • markitdown: инструмент Microsoft для конвертации документов, который преобразует PDF, Word и другие форматы в чистый markdown

  • sentence-transformers: локальная генерация эмбеддингов для преобразования текста в поисковые векторы

  • langchain-text-splitters: интеллектуальное разбиение текста на фрагменты с сохранением смысла

  • chromadb: самохостируемая векторная база данных для хранения и запросов эмбеддингов документов

  • gradio: конструктор веб-интерфейсов для создания удобных Q&A-приложений

  • langchain-ollama: интеграция LangChain для локального инференса LLM

Установите Ollama и скачайте модель:

curl -fsSL https://ollama.com/install.sh | sh
ollama pull llama3.2

Далее создайте структуру проекта для организации файлов:

mkdir processed_docs documents

Эти каталоги нужны для порядка:

  • processed_docs: хранит конвертированные markdown-файлы

  • documents: содержит исходные файлы (PDF, Word и т. п.)

Создайте эти каталоги в текущей рабочей директории с корректными правами на чтение/запись.

Подготовка датасета: техническая документация по Python

Для демонстрации RAG-конвейера мы используем книгу «Think Python» Аллена Дауни — полноценное руководство по программированию, свободно доступное по лицензии Creative Commons.

Скачаем это руководство по Python и сохраним его в каталоге documents.

import requests
from pathlib import Path


# Get the file path
output_folder = "documents"
filename = "think_python_guide.pdf"
url = "https://greenteapress.com/thinkpython/thinkpython.pdf"
file_path = Path(output_folder) / filename


def download_file(url: str, file_path: Path):
    response = requests.get(url, stream=True, timeout=30)
    response.raise_for_status()
    file_path.write_bytes(response.content)


# Download the file if it doesn't exist
if not file_path.exists():
    download_file(
        url=url,
        file_path=file_path,
    )

Далее преобразуем этот PDF в формат, который наша RAG-система сможет обрабатывать и искать по нему.

Загрузка документов с помощью MarkItDown

RAG-системам нужны документы в структурированном формате, который модели ИИ могут корректно понимать и эффективно обрабатывать.

MarkItDown решает эту задачу, конвертируя любые форматы документов в чистый markdown с сохранением исходной структуры и смысла.

Преобразование вашего Python-руководства

Начните с конвертации руководства по Python, чтобы понять, как работает MarkItDown:

from markitdown import MarkItDown

# Initialize the converter
md = MarkItDown()

# Convert the Python guide to markdown
result = md.convert(file_path)
python_guide_content = result.text_content

# Display the conversion results
print("First 300 characters:")
print(python_guide_content[:300] + "...")

В этом коде:

  • MarkItDown() создаёт конвертер документов, который автоматически обрабатывает несколько форматов.

  • convert() обрабатывает PDF и возвращает объект результата с извлечённым текстом.

  • text_content предоставляет «чистый» markdown-текст, готовый к дальнейшей обработке.

Вывод:

First 300 characters:
Think Python

How to Think Like a Computer Scientist

Version 2.0.17

Think Python

How to Think Like a Computer Scientist

Version 2.0.17

Allen Downey

Green Tea Press

Needham, Massachusetts

Copyright © 2012 Allen Downey.

Green Tea Press
9 Washburn Ave
Needham MA 02492

Permission is granted...

MarkItDown автоматически распознаёт формат PDF и извлекает «чистый» текст, сохраняя структуру книги — главы, разделы и примеры кода.

Подготовка документа к обработке

Теперь, когда вы поняли базовую конвертацию, подготовим содержимое документа к обработке. Мы сохраним текст руководства с информацией об источнике, чтобы позже использовать её при разбиении на фрагменты и извлечении данных.

# Organize the converted document
processed_document = {
    'source': file_path,
    'content': python_guide_content
}

# Create a list containing our single document for consistency with downstream processing
documents = [processed_document]

# Document is now ready for chunking and embedding
print(f"Document ready: {len(processed_document['content']):,} characters")

Вывод:

Document ready: 460,251 characters

Когда документ успешно преобразован в markdown, следующий шаг — разбить его на небольшие, удобные для поиска части.

Интеллектуальное разбиение с LangChain

Модели ИИ не могут обрабатывать целые документы из-за ограниченных окон контекста. Разбиение (chunking) делит документы на более мелкие, поисковые фрагменты при сохранении смысловой связности.

Понимание разбиения текста на простом примере

Давайте посмотрим, как работает разбиение текста на примере простого документа.

from langchain_text_splitters import RecursiveCharacterTextSplitter

# Create a simple example that will be split
sample_text = """
Machine learning transforms data processing. It enables pattern recognition without explicit programming.

Deep learning uses neural networks with multiple layers. These networks discover complex patterns automatically.

Natural language processing combines ML with linguistics. It helps computers understand human language effectively.
"""

# Apply chunking with smaller size to demonstrate splitting
demo_splitter = RecursiveCharacterTextSplitter(
    chunk_size=150,  # Small size to force splitting
    chunk_overlap=30,
    separators=["\n\n", "\n", ". ", " ", ""],  # Split hierarchy
)

sample_chunks = demo_splitter.split_text(sample_text.strip())

print(f"Original: {len(sample_text.strip())} chars → {len(sample_chunks)} chunks")

# Show chunks
for i, chunk in enumerate(sample_chunks):
    print(f"Chunk {i+1}: {chunk}")

Вывод:

Original: 336 chars → 3 chunks
Chunk 1: Machine learning transforms data processing. It enables pattern recognition without explicit programming.
Chunk 2: Deep learning uses neural networks with multiple layers. These networks discover complex patterns automatically.
Chunk 3: Natural language processing combines ML with linguistics. It helps computers understand human language effectively.

Обратите внимание, как работает разделитель текста:

  • Разбил текст длиной 336 символов на 3 фрагмента, каждый меньше лимита в 150 символов.

  • Применил перекрытие в 30 символов между соседними фрагментами.

  • Сепараторы учитывают смысловые границы в порядке приоритета: абзацы (\n\n) → предложения (.) → слова () → отдельные символы.

Обработка множества документов на масштабе

Теперь давайте настроим разделитель на более крупные фрагменты и применим его ко всем преобразованным документам.

# Configure the text splitter with Q&A-optimized settings
text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=600,         # Optimal chunk size for Q&A scenarios
    chunk_overlap=120,      # 20% overlap to preserve context
    separators=["\n\n", "\n", ". ", " ", ""]  # Split hierarchy
)

Затем используйте разделитель текста для обработки всех наших документов.

def process_document(doc, text_splitter):
    """Process a single document into chunks."""
    doc_chunks = text_splitter.split_text(doc["content"])
    return [{"content": chunk, "source": doc["source"]} for chunk in doc_chunks]


# Process all documents and create chunks
all_chunks = []
for doc in documents:
    doc_chunks = process_document(doc, text_splitter)
    all_chunks.extend(doc_chunks)

Проанализируйте, как процесс разбиения распределил содержимое по документам.

from collections import Counter

source_counts = Counter(chunk["source"] for chunk in all_chunks)
chunk_lengths = [len(chunk["content"]) for chunk in all_chunks]

print(f"Total chunks created: {len(all_chunks)}")
print(f"Chunk length: {min(chunk_lengths)}-{max(chunk_lengths)} characters")
print(f"Source document: {Path(documents[0]['source']).name}")

Вывод:

Total chunks created: 1007
Chunk length: 68-598 characters
Source document: think_python_guide.pdf

Наши текстовые фрагменты готовы. Далее мы преобразуем их в вид, который позволит выполнять «умный» поиск по сходству.

Создание поисковых эмбеддингов с SentenceTransformers

RAG-системам важно «понимать» смысл текста, а не просто совпадения по ключевым словам. SentenceTransformers преобразует текст в числовые векторы, отражающие семантические отношения, так что система находит действительно релевантную информацию, даже если точных совпадений слов нет.

Генерация эмбеддингов

Давайте сгенерируем эмбеддинги для наших текстовых фрагментов.

from sentence_transformers import SentenceTransformer

# Load Q&A-optimized embedding model (downloads automatically on first use)
model = SentenceTransformer('multi-qa-mpnet-base-dot-v1')

# Extract documents and create embeddings
documents = [chunk["content"] for chunk in all_chunks]
embeddings = model.encode(documents)

print(f"Embedding generation results:")
print(f"  - Embeddings shape: {embeddings.shape}")
print(f"  - Vector dimensions: {embeddings.shape[1]}")

В этом коде:

  • SentenceTransformer() загружает модель, оптимизированную под Q&A, которая преобразует текст в 768-мерные векторы.

  • multi-qa-mpnet-base-dot-v1 специально обучена на 215 млн пар «вопрос–ответ» для лучшей точности в Q&A-сценариях.

  • model.encode() преобразует все текстовые фрагменты в числовые эмбеддинги за один пакетный проход.

На выходе видно, что 1007 фрагментов преобразованы в 768-мерные векторы.

Embedding generation results:
  - Embeddings shape: (1007, 768)
  - Vector dimensions: 768

Тест семантического сходства

Давайте проверим семантическое сходство, задав запросы по концепциям программирования на Python:

# Test how one query finds relevant Python programming content
from sentence_transformers import util

query = "How do you define functions in Python?"
document_chunks = [
    "Variables store data values that can be used later in your program.",
    "A function is a block of code that performs a specific task when called.",
    "Loops allow you to repeat code multiple times efficiently.",
    "Functions can accept parameters and return values to the calling code."
]

# Encode query and documents
query_embedding = model.encode(query)
doc_embeddings = model.encode(document_chunks)

Теперь рассчитаем оценки сходства и отсортируем результаты. Функция util.cos_sim() вычисляет косинусное сходство между векторами и возвращает значения от 0 (нет сходства) до 1 (идентичное значение):

# Calculate similarities using SentenceTransformers util
similarities = util.cos_sim(query_embedding, doc_embeddings)[0]

# Create ranked results
ranked_results = sorted(
    zip(document_chunks, similarities), 
    key=lambda x: x[1], 
    reverse=True
)

print(f"Query: '{query}'")
print("Document chunks ranked by relevance:")
for i, (chunk, score) in enumerate(ranked_results, 1):
    print(f"{i}. ({score:.3f}): '{chunk}'")

Вывод:

Query: 'How do you define functions in Python?'
Document chunks ranked by relevance:
1. (0.674): 'A function is a block of code that performs a specific task when called.'
2. (0.607): 'Functions can accept parameters and return values to the calling code.'
3. (0.461): 'Loops allow you to repeat code multiple times efficiently.'
4. (0.448): 'Variables store data values that can be used later in your program.'

Оценки сходства демонстрируют понимание смысла: фрагменты, связанные с функциями, получают высокие баллы (0.7+), тогда как нерелевантные программные концепции — значительно ниже (0.2-).

Построение базы знаний с ChromaDB

Эти эмбеддинги показывают возможности семантического поиска, но хранение в памяти ограничено масштабируемостью. Большие наборы векторов быстро исчерпывают системные ресурсы.

Векторные базы данных дают ключевые возможности для продакшена:

  • Постоянное хранение: данные переживают перезапуски и сбои системы

  • Оптимизированная индексация: быстрый поиск по сходству с использованием алгоритмов HNSW

  • Эффективность по памяти: миллионы векторов без исчерпания RAM

  • Параллельный доступ: одновременные запросы от множества пользователей

  • Фильтрация по метаданным: поиск по свойствам и атрибутам документов

ChromaDB предоставляет эти возможности через Python-ориентированный API и безболезненно встраивается в ваш существующий конвейер данных.

Инициализация векторной базы

Сначала настроим клиент ChromaDB и создадим коллекцию для хранения векторов документов.

import chromadb

# Create persistent client for data storage
client = chromadb.PersistentClient(path="./chroma_db")

# Create collection for business documents (or get existing)
collection = client.get_or_create_collection(
    name="python_guide",
    metadata={"description": "Python programming guide"}
)

print(f"Created collection: {collection.name}")
print(f"Collection ID: {collection.id}")
Created collection: python_guide
Collection ID: 42d23900-6c2a-47b0-8253-0a9b6dad4f41

В этом коде:

  • PersistentClient(path="./chroma_db") создает локальную векторную базу, которая сохраняет данные на диск

  • get_or_create_collection() создает новую коллекцию или возвращает существующую с тем же именем

Сохранение документов с метаданными

Теперь сохраним фрагменты документов с базовыми метаданными в ChromaDB с помощью метода add().

# Prepare metadata and add documents to collection
metadatas = [{"document": Path(chunk["source"]).name} for chunk in all_chunks]

collection.add(
    documents=documents,
    embeddings=embeddings.tolist(), # Convert numpy array to list
    metadatas=metadatas, # Metadata for each document
    ids=[f"doc_{i}" for i in range(len(documents))], # Unique identifiers for each document
)

print(f"Collection count: {collection.count()}")

Вывод:

Collection count: 1007

В базе теперь хранится 1007 искомых фрагментов документов с их векторными представлениями. ChromaDB сохраняет эти данные на диск, что позволяет сразу выполнять запросы без повторной обработки документов после перезапуска.

Запрос к базе знаний

Давайте выполним поиск по векторной базе с помощью вопросов на естественном языке и извлечем релевантные фрагменты документов.

def format_query_results(question, query_embedding, documents, metadatas):
    """Format and print the search results with similarity scores"""
    from sentence_transformers import util

    print(f"Question: {question}\n")

    for i, doc in enumerate(documents):
        # Calculate accurate similarity using sentence-transformers util
        doc_embedding = model.encode([doc])
        similarity = util.cos_sim(query_embedding, doc_embedding)[0][0].item()
        source = metadatas[i].get("document", "Unknown")

        print(f"Result {i+1} (similarity: {similarity:.3f}):")
        print(f"Document: {source}")
        print(f"Content: {doc[:300]}...")
        print()


def query_knowledge_base(question, n_results=2):
    """Query the knowledge base with natural language"""
    # Encode the query using our SentenceTransformer model
    query_embedding = model.encode([question])

    results = collection.query(
        query_embeddings=query_embedding.tolist(),
        n_results=n_results,
        include=["documents", "metadatas", "distances"],
    )

    # Extract results and format them
    documents = results["documents"][0]
    metadatas = results["metadatas"][0]

    format_query_results(question, query_embedding, documents, metadatas)

В этом коде:

  • collection.query() выполняет поиск по сходству векторов, используя текст вопроса в качестве входных данных.

  • query_texts принимает список вопросов на естественном языке для пакетной обработки.

  • n_results ограничивает число возвращаемых наиболее похожих документов.

  • include задаёт, какие данные вернуть: текст документов, метаданные и значения дистанций (сходства).

Давайте проверим функцию запроса на примере вопроса:

query_knowledge_base("How do if-else statements work in Python?")

Вывод:

Question: How do if-else statements work in Python?

Result 1 (similarity: 0.636):
Document: think_python_guide.pdf
Content: 5.6 Chained conditionals

Sometimes there are more than two possibilities and we need more than two branches.
One way to express a computation like that is a chained conditional:

if x < y:
print

’

elif x > y:
’

print

x is less than y

’

x is greater than y

’

else:

print

’

x and y are equa...

Result 2 (similarity: 0.605):
Document: think_python_guide.pdf
Content: 5. An unclosed opening operator—(, {, or [—makes Python continue with the next line
as part of the current statement. Generally, an error occurs almost immediately in the
next line.

6. Check for the classic = instead of == inside a conditional.

7. Check the indentation to make sure it lines up the...

Поиск находит релевантное содержимое с высокими показателями сходства (0.636 и 0.605).

Расширенная генерация ответов с Open Source LLMs

Поиск по сходству векторов извлекает связанные фрагменты, но результаты могут быть разбросаны по нескольким кускам и не образовывать цельного ответа.

LLM справляются с этим, «сшивая» извлечённый контекст в единый ответ, который непосредственно отвечает на вопрос пользователя.

В этом разделе мы интегрируем локальные LLM из Ollama с нашим векторным поиском, чтобы генерировать связные ответы на основе полученных фрагментов.

Реализация генерации ответов

Сначала настроим компоненты для генерации ответов с помощью LLM.

from langchain_ollama import OllamaLLM
from langchain.prompts import PromptTemplate

# Initialize the local LLM
llm = OllamaLLM(model="llama3.2:latest", temperature=0.1)

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

prompt_template = PromptTemplate(
    input_variables=["context", "question"],
    template="""You are a Python programming expert. Based on the provided documentation, answer the question clearly and accurately.

Documentation:
{context}

Question: {question}

Answer (be specific about syntax, keywords, and provide examples when helpful):"""
)

# Create the processing chain
chain = prompt_template | llm

Создадим функцию, которая извлекает релевантный контекст по заданному вопросу.

def retrieve_context(question, n_results=5):
    """Retrieve relevant context using embeddings"""
    query_embedding = model.encode([question])
    results = collection.query(
        query_embeddings=query_embedding.tolist(),
        n_results=n_results,
        include=["documents", "metadatas", "distances"],
    )

    documents = results["documents"][0]
    context = "\n\n---SECTION---\n\n".join(documents)
    return context, documents


def get_llm_answer(question, context):
    """Generate answer using retrieved context"""
    answer = chain.invoke(
        {
            "context": context[:2000],
            "question": question,
        }
    )
    return answer


def format_response(question, answer, source_chunks):
    """Format the final response with sources"""
    response = f"**Question:** {question}\n\n"
    response += f"**Answer:** {answer}\n\n"
    response += "**Sources:**\n"

    for i, chunk in enumerate(source_chunks[:3], 1):
        preview = chunk[:100].replace("\n", " ") + "..."
        response += f"{i}. {preview}\n"

    return response


def enhanced_query_with_llm(question, n_results=5):
    """Query function combining retrieval with LLM generation"""
    context, documents = retrieve_context(question, n_results)
    answer = get_llm_answer(question, context)
    return format_response(question, answer, documents)

Тестирование расширенной генерации ответов

Давайте протестируем улучшенную систему на нашем сложном вопросе:

# Test the enhanced query system
enhanced_response = enhanced_query_with_llm("How do if-else statements work in Python?")
print(enhanced_response)

Вывод:

**Question:** How do if-else statements work in Python?

**Answer:** If-else statements in Python are used for conditional execution of code. Here's a breakdown of how they work:

**Syntax**

The basic syntax of an if-else statement is as follows:
```text
if condition:
    # code to execute if condition is true
elif condition2:
    # code to execute if condition1 is false and condition2 is true
else:
    # code to execute if both conditions are false
```text
**Keywords**

The keywords used in an if-else statement are:

* `if`: used to check a condition
* `elif` (short for "else if"): used to check another condition if the first one is false
* `else`: used to specify code to execute if all conditions are false

**How it works**

Here's how an if-else statement works:

1. The interpreter evaluates the condition inside the `if` block.
2. If the condition is true, the code inside the `if` block is executed.
3. If the condition is false, the interpreter moves on to the next line and checks the condition in the `elif` block.
4. If the condition in the `elif` block is true, the code inside that block is executed.
5. If both conditions are false, the interpreter executes the code inside the `else` block.


**Sources:**
1. 5.6 Chained conditionals  Sometimes there are more than two possibilities and we need more than two ...
2. 5. An unclosed opening operator—(, {, or [—makes Python continue with the next line as part of the c...
3. if x == y:  print  else:  ’  x and y are equal  ’  if x < y:  44  Chapter 5. Conditionals and recur...

Обратите внимание, как LLM организует несколько фрагментов в логические разделы с примерами синтаксиса и пошаговыми объяснениями. Такое преобразование превращает «сырое» извлечение в практические рекомендации по программированию.

Реализация потокового интерфейса


Пользователи теперь ожидают «живого» стриминга ответов, как в ChatGPT и Claude. Статические ответы, появляющиеся целиком, выглядят устаревшими и создают впечатление низкой производительности.

Побуквенная/потоковая генерация сглаживает этот разрыв, создавая привычный эффект печати, сигнализирующий об активной обработке.

Чтобы реализовать потоковый интерфейс, мы воспользуемся методом chain.stream(), который генерирует токены по одному.

def stream_llm_answer(question, context):
    """Stream LLM answer generation token by token"""
    for chunk in chain.stream({
        "context": context[:2000],
        "question": question,
    }):
        yield getattr(chunk, "content", str(chunk))

Давайте посмотрим, как работает стриминг, объединив наши модульные функции:

import time

# Test the streaming functionality
question = "What are Python loops?"
context, documents = retrieve_context(question, n_results=3)

print("Question:", question)
print("Answer: ", end="", flush=True)

# Stream the answer token by token
for token in stream_llm_answer(question, context):
    print(token, end="", flush=True)
    time.sleep(0.05)  # Simulate real-time typing effect

Вывод:

Question: What are Python loops?
Answer: Python → loops → are → structures → that → repeat → code...

[Each token appears with typing animation]
Final: "Python loops are structures that repeat code blocks."

Это создаёт знакомую анимацию «печати по буквам» в стиле ChatGPT, когда токены появляются постепенно.

Создание простого приложения на Gradio

Теперь, когда у нас есть полноценная RAG-система с расширенной генерацией ответов, сделаем её доступной через веб-интерфейс.

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

  • Нулевой веб-разработке: интерфейсы создаются прямо из Python-функций

  • Автоматической генерации UI: поля ввода и кнопки появляются сами

  • Мгновенному деплою: веб-приложения запускаются одной строкой кода

Функция интерфейса

Создадим полноценный интерфейс Gradio, который объединит написанные функции в потоковую RAG-систему.

import gradio as gr


def rag_interface(question):
    """Gradio interface reusing existing format_response function"""
    if not question.strip():
        yield "Please enter a question."
        return

    # Use modular retrieval and streaming
    context, documents = retrieve_context(question, n_results=5)

    response_start = f"**Question:** {question}\n\n**Answer:** "
    answer = ""

    # Stream the answer progressively
    for token in stream_llm_answer(question, context):
        answer += token
        yield response_start + answer

    # Use existing formatting function for final response
    yield format_response(question, answer, documents)

Настройка и запуск приложения

Теперь настроим веб-интерфейс Gradio с примерами вопросов и запустим приложение для доступа пользователей.

# Create Gradio interface with streaming support
demo = gr.Interface(
    fn=rag_interface,
    inputs=gr.Textbox(
        label="Ask a question about Python programming",
        placeholder="How do if-else statements work in Python?",
        lines=2,
    ),
    outputs=gr.Markdown(label="Answer"),
    title="Intelligent Document Q&A System",
    description="Ask questions about Python programming concepts and get instant answers with source citations.",
    examples=[
        "How do if-else statements work in Python?",
        "What are the different types of loops in Python?",
        "How do you handle errors in Python?",
    ],
    allow_flagging="never",
)

# Launch the interface with queue enabled for streaming
if __name__ == "__main__":
    demo.queue().launch(share=True)

В этом коде:

  • gr.Interface() создаёт аккуратное веб-приложение с автоматической генерацией UI

  • fn указывает функцию, которая вызывается при отправке вопроса пользователем (включая потоковый вывод)

  • inputs/outputs задают компоненты интерфейса (текстовое поле для вопросов, markdown для форматированных ответов)

  • examples предоставляет кликабельные примеры вопросов, демонстрирующие возможности системы

  • demo.queue().launch(share=True) включает потоковый вывод и создаёт локальный и публичный URL

Запуск приложения даёт следующий вывод:

* Running on local URL:  http://127.0.0.1:7861
* Running on public URL: https://bb9a9fc06531d49927.gradio.live

Протестируйте интерфейс локально или поделитесь публичной ссылкой, чтобы показать возможности вашей RAG-системы.

Публичный URL истекает через 72 часа. Для постоянного доступа задеплойте на Hugging Face Spaces.

gradio deploy

Теперь у вас есть полноценная RAG-система с поддержкой стриминга, готовая к продакшену: с генерацией токенов в реальном времени и указанием источников.

Русскоязычное сообщество про Python

Друзья! Эту статью подготовила команда Python for Devs — канала, где каждый день выходят самые свежие и полезные материалы о Python и его экосистеме. Подписывайтесь, чтобы ничего не пропустить!

Заключение

В этой статье мы построили полный RAG-пайплайн, который превращает ваши документы в систему ответов на вопросы на базе ИИ.

Мы использовали следующие инструменты:

  • MarkItDown для конвертации документов

  • LangChain для разбиения текста и генерации эмбеддингов

  • ChromaDB для хранения векторов

  • Ollama для локального инференса LLM

  • Gradio для веб-интерфейса

Поскольку все эти инструменты — Open source, вы можете без труда развернуть систему в своей инфраструктуре.

Комментарии (14)


  1. webhamster
    16.10.2025 14:11

    Когда происходит разбивка на фрагменты, естественным образом должен возникнуть вопрос: а откуда, собственно, этот фрагмент взят? То есть, надо иметь ссылку не просто на 750-страничный документ, а ссылку либо на раздел/главу/параграф в этом документе, или, на худой конец, на номер страницы в PDF/DOC/ODT файле.

    Есть ли какой-либо способ получать именно такую ссылку, чтобы показывать пользователю в ответе используемые материалы не просто:
    .
    Найдены документы:

    1 . Питон_для_чайников.pdf

    2 . Этот_сумасшедший_змей.pdf

    а

    Найдены документы:

    1 . Питон_для_чайников.pdf,

    Часть II. Системное программирование,

    Глава 3. Контекст выполнения сценариев,

    Перенаправление потоков ввода-вывода

    2 . Этот_сумасшедший_змей.pdf

    стр. 255, 897

    .

    Возможно, должен быть какой-то стандарт записи такого адреса. И еще надо иметь возможность открыть документ именно на этом месте с помощью, конечно, OpenSource решения.


    1. avshkol
      16.10.2025 14:11

      Согласен, у каждого документа нужно собирать реквизиты документа, и оглавление, и помечать каждый кусок текста в базе ссылкой на этот документ, в идеале на главу/раздел.

      Но, насколько я понимаю, чтобы такое делать, проще пульнуть документ в LLM с большим окном токенов, и получить оттуда данные в типовом формате. См. мою статью с таким разбором: https://habr.com/ru/articles/879876/

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

      А еще стоит вопрос сохранения рисунков, таблиц и формул...


  1. aladkoi
    16.10.2025 14:11

    Еще нужно создать граф связей между пересекающимися данными в чанках. И так, как описано в статье, создание dataset для rag в формате вопрос - ответ , Claude.ai делать не рекомендует.