RAG Isn't Just Retrieval: Chunking, Re-Ranking, and Graph Strategies That Make Agents Actually Work
Chapter 6: Retrieval-Augmented Generation: Building a Knowledge-Base-Driven Agent
If an Agent relies solely on the LLM's built-in knowledge, it's like a student who only relies on memory—knowledge is limited and becomes outdated. RAG (Retrieval-Augmented Generation) allows an Agent to retrieve professional, real-time information from an external knowledge base, significantly improving the accuracy and depth of its answers. This chapter will systematically explain RAG technology, from full-process analysis to advanced strategies.
6.1 Full RAG Process Analysis: Data Cleaning, Chunking, and Index Construction
The Core RAG Process
Offline Phase: Documents -> Cleaning -> Chunking -> Embedding -> Vector Database
Online Phase: Question -> Embedding -> Retrieval -> Insert into Prompt -> LLM Generation
Data Cleaning
Raw documents are often chaotically formatted and contain noise. Cleaning quality directly affects retrieval results:
import re
class DocumentCleaner:
"""Document cleaning utility"""
def clean(self, text: str) -> str:
text = self._remove_boilerplate(text)
text = self._normalize_whitespace(text)
text = self._remove_special_chars(text)
return text.strip()
def _remove_boilerplate(self, text: str) -> str:
"""Remove boilerplate text like headers, footers, and copyright notices"""
patterns = [
r"Copyright.*?All rights reserved",
r"This document is for reference only.*?does not constitute any advice",
r"Page\s*\d+\s*/\s*\d+",
]
for pattern in patterns:
text = re.sub(pattern, "", text, flags=re.IGNORECASE)
return text
def _normalize_whitespace(self, text: str) -> str:
"""Normalize whitespace characters"""
text = re.sub(r"\n{3,}", "\n\n", text) # Compress multiple newlines to two
text = re.sub(r"[ \t]+", " ", text) # Compress multiple spaces to one
return text
def _remove_special_chars(self, text: str) -> str:
"""Remove invisible characters and garbled text"""
text = re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]", "", text)
return text
Text Chunking Strategies
Chunking is the most critical step in RAG. Chunks that are too large reduce retrieval precision; chunks that are too small lack complete context.
Strategy 1: Fixed-Length Chunking
from langchain.text_splitter import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=500, # 500 characters per chunk
chunk_overlap=50, # 50-character overlap between chunks
separators=["\n\n", "\n", "。", "!", "?", ";", " "],
)
Strategy 2: Semantic Chunking
Chunk by semantic boundaries (paragraphs, sections) rather than mechanical cuts:
from langchain.text_splitter import MarkdownHeaderTextSplitter
# Chunk by Markdown heading levels
md_splitter = MarkdownHeaderTextSplitter(
headers_to_split_on=[
("#", "h1"),
("##", "h2"),
("###", "h3"),
]
)
chunks = md_splitter.split_text(markdown_doc)
# Each chunk automatically carries its heading hierarchy information
Strategy 3: Parent-Child Chunking (Small-to-Big Retrieval)
Use small chunks for retrieval (high precision) and large chunks for return (complete context):
from langchain.retrievers import ParentDocumentRetriever
from langchain.storage import InMemoryStore
# Child splitter (for retrieval)
child_splitter = RecursiveCharacterTextSplitter(chunk_size=200)
# Parent splitter (for return)
parent_splitter = RecursiveCharacterTextSplitter(chunk_size=1000)
vectorstore = Chroma(embedding_function=embeddings)
docstore = InMemoryStore() # Stores parent document originals
retriever = ParentDocumentRetriever(
vectorstore=vectorstore,
docstore=docstore,
child_splitter=child_splitter,
parent_splitter=parent_splitter,
)
| Chunking Strategy | Advantages | Disadvantages | Use Cases |
|---|---|---|---|
| Fixed-Length | Simple and controllable | May cut off semantics | General scenarios |
| Semantic | Preserves semantic integrity | Depends on document structure | Markdown/HTML documents |
| Parent-Child | Balances precision and context | Complex implementation | High-quality RAG |
Index Construction
from langchain_community.vectorstores import Chroma
from langchain_openai import OpenAIEmbeddings
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
# Build index from documents
vectorstore = Chroma.from_documents(
documents=chunks,
embedding=embeddings,
persist_directory="./knowledge_base",
collection_metadata={"hnsw:space": "cosine"} # Use cosine similarity
)
Reference: LangChain Text Splitters
6.2 Advanced Retrieval Strategies: Hybrid Search and Re-ranking Techniques
Limitations of Basic Vector Retrieval
Pure vector retrieval has a fundamental problem: semantic similarity does not equal answer relevance. For example, if a user asks "How to install Python," vector retrieval might return an article titled "Various Methods to Install Python" with outdated content, while missing a tutorial with a less relevant title but precise content.
Hybrid Search: Vector + Keyword
Hybrid search combines vector retrieval (semantic matching) and BM25 keyword retrieval (exact matching) to complement each other:
from langchain.retrievers import EnsembleRetriever
from langchain_community.retrievers import BM25Retriever
# Vector retriever
vector_retriever = vectorstore.as_retriever(search_kwargs={"k": 10})
# BM25 keyword retriever
bm25_retriever = BM25Retriever.from_documents(chunks, k=10)
# Ensemble retriever
ensemble_retriever = EnsembleRetriever(
retrievers=[vector_retriever, bm25_retriever],
weights=[0.5, 0.5] # 50% weight each for vector and keyword
)
results = ensemble_retriever.invoke("Python installation tutorial")
Re-ranking
Retrieved documents are sorted by similarity, but the most similar may not be the most relevant. A re-ranking model can more accurately assess document relevance to the query:
from langchain.retrievers import ContextualCompressionRetriever
from langchain_cohere import CohereRerank
# Use Cohere re-ranking model
compressor = CohereRerank(model="rerank-v3.5", top_n=5)
compression_retriever = ContextualCompressionRetriever(
base_compressor=compressor,
base_retriever=ensemble_retriever
)
# First retrieve a large number of candidates, then re-rank to filter the top 5 most relevant
results = compression_retriever.invoke("How to optimize RAG retrieval performance?")
Query Rewriting and Expansion
A user's original query may not be precise enough. Using an LLM to rewrite or expand the query can improve retrieval recall:
class QueryRewriter:
def __init__(self, llm):
self.llm = llm
def expand_query(self, original_query: str) -> list[str]:
"""Expand the original query into multiple sub-queries"""
prompt = f"""
Original query: {original_query}
Please generate 3 sub-queries from different angles to help retrieve more comprehensive information:
1. A synonym-replaced version
2. A more specific, detailed version
3. A broader, higher-level version
"""
response = self.llm.invoke(prompt)
return [original_query] + self._parse_queries(response.content)
def hyde_query(self, original_query: str) -> str:
"""HyDE: Have the LLM generate a hypothetical answer first, then use the answer for retrieval"""
prompt = f"Please answer the following question (give your best guess even if unsure): {original_query}"
hypothetical_answer = self.llm.invoke(prompt).content
return hypothetical_answer # Use the hypothetical answer as the retrieval query
Reference Paper: HyDE: Precise Zero-Shot Dense Retrieval
6.3 Combining Knowledge Graphs and Agents: Utilizing Structured Data
Blind Spots of Vector Retrieval
Vector retrieval excels at semantic matching but struggles with entity relationships and structured queries. For example, "What other companies were founded by the people who co-founded PayPal with Elon Musk?" — this type of multi-hop relational query is almost impossible for vector retrieval to handle.
Knowledge Graph-Enhanced RAG
from langchain_community.graphs import Neo4jGraph
from langchain.chains import GraphCypherQAChain
# Connect to knowledge graph
graph = Neo4jGraph(
url="bolt://localhost:7687",
username="neo4j",
password="your_password"
)
# Use LLM to generate Cypher queries
chain = GraphCypherQAChain.from_llm(
llm=ChatOpenAI(model="gpt-4o", temperature=0),
graph=graph,
verbose=True,
)
result = chain.run("What other companies were founded by the people who co-founded PayPal with Elon Musk?")
# LLM automatically generates: MATCH (p:Person)-[:COFOUNDED]->(c:Company {name: 'PayPal'})<-[:COFOUNDED]-(other:Person)
# MATCH (other)-[:COFOUNDED]->(other_c:Company)
# RETURN other.name, collect(other_c.name)
GraphRAG: Microsoft's Graph-Enhanced Solution
Microsoft's GraphRAG solution process:
- Extraction: Extract entities and relationships from documents
- Construction: Build a community graph to discover entity clusters
- Indexing: Generate summaries for each community
- Retrieval: First locate relevant communities, then retrieve within them
# Simplified entity extraction
def extract_entities_and_relations(text: str, llm) -> dict:
prompt = f"""Extract entities and relations from the following text, returning in JSON format:
Text: {text}
Format: {{
"entities": [{{"name": "entity name", "type": "type"}}],
"relations": [{{"source": "entity1", "target": "entity2", "relation": "relationship"}}]
}}"""
response = llm.invoke(prompt)
return json.loads(response.content)
Reference: Microsoft GraphRAG
6.4 Solving RAG Pain Points: Lost in the Middle and Multi-Hop Reasoning
Pain Point 1: Lost in the Middle
Research shows that LLMs pay the least attention to information in the middle of the context. When retrieval returns multiple document fragments, those placed in the middle are easily overlooked.
Solution: Document Reordering
def relevance_ordered_placement(documents: list[str], strategy: str = "descending") -> list[str]:
"""Reorder documents by relevance to avoid placing important information in the middle"""
# Option 1: Descending order - most relevant first
if strategy == "descending":
return documents # Keep retrieval order
# Option 2: Alternating order - alternate most and least relevant
if strategy == "alternating":
result = []
left, right = 0, len(documents) - 1
while left <= right:
result.append(documents[left])
if left != right:
result.append(documents[right])
left += 1
right -= 1
return result
return documents
Pain Point 2: Multi-Hop Reasoning
"What is the birthplace of the wife of the 46th President of the United States?" — This requires two-hop reasoning: Who is the 46th President -> Who is his wife -> What is her birthplace.
Solution: Iterative Retrieval
class MultiHopRetriever:
def __init__(self, retriever, llm):
self.retriever = retriever
self.llm = llm
def retrieve(self, query: str, max_hops: int = 3) -> list[str]:
all_docs = []
current_query = query
for hop in range(max_hops):
docs = self.retriever.invoke(current_query)
all_docs.extend(docs)
# Determine if further retrieval is needed
judge_prompt = f"""
Original question: {query}
Retrieved information: {[d.page_content[:200] for d in all_docs]}
Can the question be fully answered now?
If not, please generate the sub-question needed for the next retrieval step.
"""
response = self.llm.invoke(judge_prompt).content
if "yes" in response.lower() and "complete" in response.lower():
break
# Extract the sub-question for the next retrieval step
current_query = response.split("Sub-question:")[-1].strip() if "Sub-question:" in response else query
return all_docs
Pain Point 3: Information Conflicts
Information from different sources may contradict each other:
def resolve_conflicts(documents: list[str], query: str, llm) -> str:
"""Handle information conflicts"""
conflict_prompt = f"""
Question: {query}
Retrieved information (may contain conflicts):
{chr(10).join(f'Source {i+1}: {d}' for i, d in enumerate(documents))}
Please analyze:
1. Which pieces of information conflict with each other?
2. What are the possible reasons for each conflict (timeliness, source reliability, etc.)?
3. Which version do you tend to adopt? Why?
"""
return llm.invoke(conflict_prompt).content
6.5 Dynamic Knowledge Base Updates: Incremental Indexing and Version Management Strategies
Why Dynamic Updates Are Needed
A knowledge base is not static. Product documentation gets updated, policies and regulations change, and technical solutions evolve. An outdated knowledge base is more dangerous than having none at all—it will provide incorrect information.
Incremental Indexing
class IncrementalIndexer:
def __init__(self, vectorstore, embeddings):
self.vectorstore = vectorstore
self.embeddings = embeddings
self.doc_hashes: dict[str, str] = {} # doc_id -> content_hash
def _content_hash(self, content: str) -> str:
import hashlib
return hashlib.md5(content.encode()).hexdigest()
def update(self, documents: list) -> dict:
"""Incrementally update the index"""
added, updated, unchanged = 0, 0, 0
for doc in documents:
doc_id = doc.metadata.get("doc_id", str(id(doc)))
new_hash = self._content_hash(doc.page_content)
if doc_id not in self.doc_hashes:
# New document
self.vectorstore.add_documents([doc])
self.doc_hashes[doc_id] = new_hash
added += 1
elif self.doc_hashes[doc_id] != new_hash:
# Document changed, delete old version and add new version
self.vectorstore.delete(ids=[doc_id])
self.vectorstore.add_documents([doc])
self.doc_hashes[doc_id] = new_hash
updated += 1
else:
unchanged += 1
return {"added": added, "updated": updated, "unchanged": unchanged}
Version Management Strategy
class VersionedKnowledgeBase:
def __init__(self, vectorstore):
self.vectorstore = vectorstore
self.versions: dict[str, list[dict]] = {} # doc_id -> version list
def add_version(self, doc_id: str, content: str, metadata: dict = None):
"""Add a new version of a document"""
version = {
"content": content,
"metadata": metadata or {},
"timestamp": datetime.now().isoformat(),
"version": len(self.versions.get(doc_id, [])) + 1
}
self.versions.setdefault(doc_id, []).append(version)
# Only write the latest version to the vector store (marked as current)
self.vectorstore.add_documents([{
"page_content": content,
"metadata": {**(metadata or {}), "doc_id": doc_id, "version": version["version"], "status": "current"}
}])
def rollback(self, doc_id: str, target_version: int):
"""Roll back to a specified version"""
if doc_id not in self.versions:
return False
versions = self.versions[doc_id]
target = next((v for v in versions if v["version"] == target_version), None)
if not target:
return False
# Delete current version, restore target version
self.add_version(doc_id, target["content"], target["metadata"])
return True
Automatic Update Trigger Mechanism
import hashlib
from datetime import datetime
class AutoUpdater:
"""Monitor source file changes and automatically trigger index updates"""
def __init__(self, indexer: IncrementalIndexer, source_dir: str):
self.indexer = indexer
self.source_dir = source_dir
self.file_hashes: dict[str, str] = {}
def scan_and_update(self) -> dict:
"""Scan the source directory, detect changes, and update"""
changes = {"added": [], "modified": [], "deleted": []}
# Scan current files
current_files = {}
for root, dirs, files in os.walk(self.source_dir):
for f in files:
if f.endswith(('.md', '.txt', '.pdf', '.docx')):
filepath = os.path.join(root, f)
with open(filepath, 'rb') as file:
file_hash = hashlib.md5(file.read()).hexdigest()
current_files[filepath] = file_hash
# Detect additions and modifications
for filepath, file_hash in current_files.items():
if filepath not in self.file_hashes:
changes["added"].append(filepath)
elif self.file_hashes[filepath] != file_hash:
changes["modified"].append(filepath)
# Detect deletions
for filepath in self.file_hashes:
if filepath not in current_files:
changes["deleted"].append(filepath)
# Execute updates
# ... (load changed files, chunk, write to index)
self.file_hashes = current_files
return changes
Chapter Summary
| RAG Stage | Key Technology | Core Point |
|---|---|---|
| Data Cleaning | Regex denoising, format normalization | Garbage in = garbage out; cleaning is the foundation |
| Chunking Strategy | Fixed-length / Semantic / Parent-Child | Parent-child chunking balances precision and context |
| Hybrid Retrieval | Vector + BM25 + Re-ranking | Semantic matching and exact matching complement each other |
| Query Optimization | Rewriting / Expansion / HyDE | Use LLM to optimize retrieval queries |
| Knowledge Graph | Cypher + GraphRAG | Structured relationships + multi-hop reasoning |
| Pain Point Solutions | Document reordering / Iterative retrieval / Conflict resolution | Lost in the middle / Multi-hop / Information conflicts |
| Dynamic Updates | Incremental indexing / Version management / Automatic scanning | A knowledge base is not a one-time project |
In the next chapter, we will move from single Agents to multi-Agents—enabling multiple intelligent agents to work together to solve more complex systemic problems.