Реалізація Parent Document Retriever для RAG
Parent Document Retriever — архітектурний паттерн RAG, який вирішує фундаментальне протиріччя: для точного retrieval потрібні маленькі фрагменти (краща семантична точність), але для якісної генерації потрібен широкий контекст (повний розділ, а не 3 речення). Рішення: індексуємо маленькі «дочірні» фрагменти, а в LLM передаємо їхні «батьківські» великі документи.
Архітектура Parent Document Retriever
Індексування:
├── Документ (2000 токенів)
│ ├── Child chunk 1 (128 токенів) → embedding → індекс
│ ├── Child chunk 2 (128 токенів) → embedding → індекс
│ ├── Child chunk 3 (128 токенів) → embedding → індекс
│ └── Child chunk 4 (128 токенів) → embedding → індекс
Пошук:
├── Query → пошук за child embeddings
├── Знайден child_chunk_3 (висока релевантність)
└── Повертаємо parent document (2000 токенів) → в LLM
Реалізація з LangChain
from langchain.retrievers import ParentDocumentRetriever
from langchain.storage import InMemoryByteStore, LocalFileStore
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_community.vectorstores import Qdrant
from langchain_openai import OpenAIEmbeddings
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
# Сховище батьківських документів (persistent)
store = LocalFileStore("./parent_docs_store")
# Спліттери: child дрібний, parent крупний
child_splitter = RecursiveCharacterTextSplitter(
chunk_size=200,
chunk_overlap=20,
)
parent_splitter = RecursiveCharacterTextSplitter(
chunk_size=2000,
chunk_overlap=100,
)
vectorstore = Qdrant.from_texts(
texts=[], # Порожній — заповнюється через retriever
embedding=embeddings,
collection_name="child_chunks",
url="http://localhost:6333",
)
retriever = ParentDocumentRetriever(
vectorstore=vectorstore,
docstore=store,
child_splitter=child_splitter,
parent_splitter=parent_splitter,
)
# Індексування
retriever.add_documents(documents, ids=None)
# Запит — повертає батьківські документи
relevant_docs = retriever.invoke("процедура узгодження закупки")
print(f"Знайдено {len(relevant_docs)} батьківських документів")
print(f"Розмір першого: {len(relevant_docs[0].page_content)} символів")
Трьохрівнева ієрархія
Для складних документів можна використовувати три рівні: документ → секція → параграф:
from langchain.retrievers import ParentDocumentRetriever
# Sub-chunk (для індексування) → chunk (parent) → section (grandparent)
sub_splitter = RecursiveCharacterTextSplitter(chunk_size=100, chunk_overlap=10)
chunk_splitter = RecursiveCharacterTextSplitter(chunk_size=500, chunk_overlap=50)
retriever = ParentDocumentRetriever(
vectorstore=vectorstore,
docstore=store,
child_splitter=sub_splitter,
parent_splitter=chunk_splitter,
)
Практичне порівняння підходів
Набір даних: технічні регламенти (середній документ 3500 слів, 20–40 розділів).
| Підхід | Chunk у індексі | Контекст в LLM | Context Recall | Faithfulness |
|---|---|---|---|---|
| Стандартний (512 токенів) | 512 | 512×5=2560 | 0.69 | 0.81 |
| Стандартний (256 токенів) | 256 | 256×5=1280 | 0.74 | 0.78 |
| Parent Doc (child=200, parent=1500) | 200 | 1500×3=4500 | 0.88 | 0.91 |
| Parent Doc + Reranker | 200 | 1500×3=4500 | 0.88 | 0.94 |
Parent Document Retriever значно поліпшує context recall (+19%) при високій достовірності: дочірні фрагменти точно знаходять потрібний розділ, батьківські документи надають повний контекст.
Кешування батьківських документів
При високому QPS батьківські документи варто кешувати в Redis:
import redis
import json
redis_client = redis.Redis(host="localhost", port=6379)
class CachedParentDocumentRetriever:
def __init__(self, base_retriever, ttl: int = 3600):
self.retriever = base_retriever
self.ttl = ttl
def invoke(self, query: str) -> list:
# Пошук дочірніх фрагментів
child_docs = self.retriever.vectorstore.similarity_search(query, k=5)
# Завантажуємо батьків з кешем
parent_docs = []
for child in child_docs:
parent_id = child.metadata.get("doc_id")
cache_key = f"parent:{parent_id}"
cached = redis_client.get(cache_key)
if cached:
parent_docs.append(json.loads(cached))
else:
parent = self.retriever.docstore.mget([parent_id])[0]
if parent:
redis_client.setex(cache_key, self.ttl, json.dumps(parent.dict()))
parent_docs.append(parent)
return parent_docs
Часові рамки
- Налаштування Parent Document Retriever: 2–3 дні
- Підбір оптимальних розмірів фрагментів: 2–3 дні
- Тестування та оцінка: 2–3 дні
- Всього: 1 тиждень







