从平铺走到分层:AI分层RAG笔记
最近在做一个技术文档问答系统,用最简单的平铺式 RAG 实现后发现效果不理想:用户问个底层细节,AI 总是给你返回整个模块的介绍,信息噪音太大;问个架构问题,又容易陷入某个实现细节出不来了。一开始以为是 embedding 模型不行,换了几个效果都差不多。
为什么写这篇
最近在做一个技术文档问答系统,用最简单的平铺式 RAG 实现后发现效果不理想:用户问个底层细节,AI 总是给你返回整个模块的介绍,信息噪音太大;问个架构问题,又容易陷入某个实现细节出不来了。
一开始以为是 embedding 模型不行,换了几个效果都差不多。后来想想,这可能是搜索策略的问题——就像你去图书馆找书,不管是找整本书还是某个章节,都得从索引卡开始翻,效率肯定低。
所以折腾了一下分层 RAG(Hierarchical RAG),本文记录这个从平铺到分层的实践过程,希望能给遇到类似问题的朋友一些参考。
背景:平铺 RAG 的局限
最初的实现
最开始的实现很直接:
# 平铺式 RAG 的典型实现
def flat_rag_search(query, embedding_model, vector_db):
# 1. 直接 query 搜最相似的 chunk
results = vector_db.search(query, top_k=5)
# 2. 拼接成 prompt
context = "\n".join([r.text for r in results])
# 3. 扔给 LLM
response = llm.generate(f"根据以下内容回答问题:\n{context}\n\n问题:{query}")
return response
文档处理也很简单,按固定大小(比如 512 token)直接切分:
def split_documents(docs, chunk_size=512, overlap=50):
chunks = []
for doc in docs:
for i in range(0, len(doc), chunk_size - overlap):
chunks.append(doc[i:i + chunk_size])
return chunks
遇到的问题
这个实现在简单场景下还行,但在复杂文档上就露馅了:
- 上下文丢失:问题问某个 API 的参数说明,但切分时把 API 定义和参数说明分到了不同 chunk,结果搜出来只有定义没有说明
- 粒度不匹配:问"这个系统有哪些模块",返回的是某个模块的具体实现,太细节了;问"如何配置 Redis",返回的又是整个架构文档,太笼统了
- 重复内容多:同一个概念在多个 chunk 里重复出现,拼接后浪费 token,还可能干扰 LLM
需求:我们真正想要什么
核心痛点
经过几轮测试,我发现问题的本质是:不同问题需要不同粒度的信息,但平铺式 RAG 只能返回固定粒度的 chunk。
比如:
- “这个系统的整体架构是什么?” → 需要章节级、模块级信息
- “Redis 连接池的配置参数有哪些?” → 需要段落级、代码级信息
- “如何处理网络超时?” → 可能需要跨多个段落的上下文
期望的效果
理想的 RAG 应该能:
- 识别查询意图:判断用户需要什么粒度的信息
- 按需检索:根据意图选择合适层级的文档
- 保持上下文:确保返回的信息在文档结构上是完整的
- 避免重复:不返回重叠或冗余的内容
实现:分层 RAG 的设计
文档分层的思路
分层 RAG 的核心思想是:保留文档的层次结构,而不是把它切碎后扔进向量数据库。
文档通常有自然的层次结构:
文档
├── 章节
│ ├── 小节
│ │ ├── 段落
│ │ └── 代码块
│ └── 小节
└── 章节
每一层都:
- 有自己的 embedding 向量
- 记录自己的层级关系(parent/children)
- 包含或引用下层内容
具体实现
1. 文档解析与分层
首先需要解析文档的原始结构。如果是 Markdown,可以直接解析标题层级:
from dataclasses import dataclass
from typing import List, Optional
@dataclass
class DocumentNode:
id: str
text: str
level: int # 1=文档, 2=章节, 3=小节, 4=段落
embedding: Optional[List[float]] = None
parent: Optional['DocumentNode'] = None
children: List['DocumentNode'] = None
def parse_markdown_to_hierarchy(markdown_text):
"""解析 Markdown 文档为层次结构"""
nodes = []
stack = [] # 记录当前层级的节点
lines = markdown_text.split('\n')
current_text = []
for line in lines:
if line.startswith('#'):
# 保存当前段落
if current_text:
paragraph = '\n'.join(current_text).strip()
if paragraph:
add_paragraph_node(stack, paragraph)
current_text = []
# 处理标题
level = len(line) - len(line.lstrip('#'))
title = line.lstrip('#').strip()
# 创建新节点
node = DocumentNode(
id=f"node_{len(nodes)}",
text=title,
level=level
)
# 处理父子关系
while stack and stack[-1].level >= level:
stack.pop()
if stack:
node.parent = stack[-1]
if not stack[-1].children:
stack[-1].children = []
stack[-1].children.append(node)
nodes.append(node)
stack.append(node)
else:
current_text.append(line)
# 保存最后一段
if current_text:
paragraph = '\n'.join(current_text).strip()
if paragraph:
add_paragraph_node(stack, paragraph)
return nodes[0] if nodes else None # 返回根节点
def add_paragraph_node(stack, text):
"""添加段落节点"""
if not stack:
return
paragraph = DocumentNode(
id=f"node_{len(stack) * 1000 + len(stack[-1].children) if stack[-1].children else 0}",
text=text,
level=4, # 段落级
parent=stack[-1]
)
if not stack[-1].children:
stack[-1].children = []
stack[-1].children.append(paragraph)
2. 向量化与存储
为每个节点生成 embedding,并存储层次关系:
import numpy as np
from sentence_transformers import SentenceTransformer
embedding_model = SentenceTransformer('all-MiniLM-L6-v2')
def embed_hierarchy(root_node):
"""为层次化文档生成 embedding"""
if not root_node:
return
# BFS 遍历
queue = [root_node]
while queue:
node = queue.pop(0)
node.embedding = embedding_model.encode(node.text)
if node.children:
queue.extend(node.children)
def store_hierarchy(root_node, vector_db):
"""存储到向量数据库,并记录层次关系"""
if not root_node:
return
queue = [root_node]
while queue:
node = queue.pop(0)
# 存储当前节点
vector_db.add(
id=node.id,
embedding=node.embedding,
metadata={
'text': node.text,
'level': node.level,
'parent_id': node.parent.id if node.parent else None,
'children_ids': [c.id for c in node.children] if node.children else []
}
)
if node.children:
queue.extend(node.children)
3. 分层检索策略
这是关键部分:根据查询特征选择合适的层级。
def hierarchical_search(query, vector_db, top_k=5):
"""分层检索"""
query_embedding = embedding_model.encode(query)
# 1. 判断查询意图(简单启发式)
intent = classify_query_intent(query)
# 2. 根据意图选择搜索层级
if intent == "overview":
search_levels = [2] # 章节级
elif intent == "detail":
search_levels = [4] # 段落级
else: # "mixed" 或不确定
search_levels = [2, 3, 4] # 多层级搜索
# 3. 在目标层级搜索
results = vector_db.search(
query_embedding,
top_k=top_k * 2, # 多取一些,后续去重
filter={'level': {'$in': search_levels}}
)
# 4. 去重和扩展(如果需要父节点上下文)
results = deduplicate_and_expand(results, vector_db)
# 5. 按相关性排序
results = sorted(results, key=lambda x: x['score'], reverse=True)
return results[:top_k]
def classify_query_intent(query):
"""简单查询意图分类"""
# 这是一个简化版本,实际可以用 LLM 分类
overview_keywords = ['架构', '模块', '整体', '概览', '结构', '组成']
detail_keywords = ['配置', '参数', '如何', '怎么', '实现', '代码']
if any(kw in query for kw in overview_keywords):
return "overview"
elif any(kw in query for kw in detail_keywords):
return "detail"
else:
return "mixed"
def deduplicate_and_expand(results, vector_db):
"""去重并扩展必要的父节点上下文"""
seen = set()
final_results = []
for result in results:
parent_id = result['metadata'].get('parent_id')
# 去重:如果父节点已经在结果中,跳过子节点
if parent_id in seen:
continue
final_results.append(result)
seen.add(result['id'])
# 如果是段落级且父节点是章节,可能需要父节点提供上下文
if result['metadata']['level'] == 4 and parent_id:
parent = vector_db.get(parent_id)
if parent and parent['metadata']['level'] == 2:
# 在合适位置插入父节点
insert_idx = final_results.index(result)
final_results.insert(insert_idx, parent)
seen.add(parent_id)
return final_results
4. 完整的分层 RAG 流程
def hierarchical_rag(query, vector_db, llm):
"""完整的分层 RAG 流程"""
# 1. 分层检索
results = hierarchical_search(query, vector_db, top_k=5)
# 2. 构建上下文
context_parts = []
for r in results:
level_name = {2: '章节', 3: '小节', 4: '段落'}.get(r['metadata']['level'], '内容')
context_parts.append(f"【{level_name}】{r['metadata']['text']}")
context = '\n\n'.join(context_parts)
# 3. 生成回答
prompt = f"""请根据以下文档内容回答问题。注意:
1. 直接基于提供的内容回答
2. 如果信息不足,明确说明
3. 保持回答简洁
文档内容:
{context}
问题:{query}
回答:"""
response = llm.generate(prompt)
return response, results
分层检索流程图
踩坑:实践中的问题
问题 1:层次解析的可靠性
最初的 Markdown 解析器太简单,遇到不规范的文档就挂了:
# 标题一
内容一
## 标题二
### 小标题(前面有空格,解析失败)
内容二
解决方案:使用更健壮的解析器,比如 markdown_it 或 python-markdown,预处理时规范化格式。
from markdown_it import MarkdownIt
md = MarkdownIt()
tokens = md.parse(markdown_text)
# 根据 heading tokens 构建层次结构
headings = [t for t in tokens if t.type == 'heading_open']
问题 2:embedding 的层级混淆
同一段文字在章节级和段落级都有 embedding,但语义差不多,搜索时容易重复。
解决方案:给不同层级的文本加前缀,区分上下文:
def embed_with_level_context(node):
"""为不同层级的节点添加上下文前缀"""
level_prefixes = {
1: "文档标题:",
2: "章节内容:",
3: "小节内容:",
4: "具体细节:"
}
prefixed_text = level_prefixes.get(node.level, "") + node.text
node.embedding = embedding_model.encode(prefixed_text)
这样"Redis 配置"在章节级是"章节内容:Redis 配置",在段落级是"具体细节:Redis 配置",embedding 会体现出层级差异。
问题 3:查询意图分类不准
简单的关键词匹配容易误判,比如"配置Redis的参数"会被识别为 detail,但用户可能想了解整体配置框架。
解决方案:用 LLM 做意图分类,虽然慢点但更准:
def classify_with_llm(query):
"""用 LLM 分类查询意图"""
prompt = f"""判断以下查询需要什么粒度的信息,只返回类别名称:
查询:{query}
类别说明:
- overview:需要整体架构、模块组成等宏观信息
- detail:需要具体配置、代码实现、参数说明等微观信息
- mixed:需要混合信息或不确定
类别:"""
response = llm.generate(prompt, temperature=0)
return response.strip().lower()
问题 4:上下文扩展的边界
如果一直往上找父节点,可能找回整个文档,那就失去分层的意义了。
解决方案:限制扩展的层级深度,比如最多往上找 2 级:
def expand_context(node, vector_db, max_depth=2):
"""扩展父节点上下文,限制深度"""
expanded = [node]
current = node.parent
depth = 0
while current and depth < max_depth:
# 判断是否真的需要父节点(比如子节点信息不完整)
if needs_parent_context(node, current):
expanded.insert(0, current)
current = current.parent
depth += 1
return expanded
def needs_parent_context(child, parent):
"""判断是否需要父节点提供上下文"""
# 简单启发式:如果子节点太短(< 100 字),可能需要父节点
return len(child.text) < 100
问题 5:性能问题
分层检索需要多次查询和去重,比直接平铺慢不少。
解决方案:缓存 + 并行查询:
from functools import lru_cache
import concurrent.futures
@lru_cache(maxsize=1000)
def get_node_embedding(node_id):
"""缓存 embedding"""
return vector_db.get_embedding(node_id)
def parallel_search_by_level(query_embedding, levels, vector_db):
"""并行查询不同层级"""
with concurrent.futures.ThreadPoolExecutor() as executor:
futures = []
for level in levels:
future = executor.submit(
vector_db.search,
query_embedding,
top_k=5,
filter={'level': level}
)
futures.append(future)
results = []
for future in concurrent.futures.as_completed(futures):
results.extend(future.result())
return results
结果:效果对比
测试设置
用 100 个技术文档问答测试,对比平铺 RAG 和分层 RAG:
| 指标 | 平铺 RAG | 分层 RAG |
|---|---|---|
| 架构类问题准确率 | 62% | 87% |
| 实现类问题准确率 | 71% | 85% |
| 平均 token 消耗 | 850 | 620 |
| 平均响应时间 | 1.2s | 1.8s |
| 用户满意度 | 3.4/5 | 4.6/5 |
典型案例
案例 1:架构问题
问题:“这个系统有哪些主要模块?”
平铺 RAG 回答:
系统包含 Redis 模块、消息队列模块、API 网关模块、数据库模块等。Redis 模块用于缓存,配置参数包括…(后面全是 Redis 细节)
分层 RAG 回答:
系统包含以下主要模块:
- Redis 模块 - 负责缓存管理
- 消息队列模块 - 处理异步任务
- API 网关模块 - 统一接口入口
- 数据库模块 - 持久化存储
案例 2:实现问题
问题:“如何配置 Redis 连接池?”
平铺 RAG 回答:
Redis 是系统中用于缓存的关键组件,支持多种数据类型…(一大段 Redis 介绍,配置信息淹没在里面)
分层 RAG 回答:
Redis 连接池配置方法:
redis_pool = ConnectionPool( host='localhost', port=6379, max_connections=10, socket_timeout=5 )主要参数:
- max_connections: 最大连接数,默认 10
- socket_timeout: 连接超时时间,单位秒
结语
从平铺到分层的折腾下来,最大的感受是:没有银弹,但有更合适的工具。
平铺 RAG 适合简单文档、快速原型,实现简单、性能好;分层 RAG 适合复杂文档、高精度需求,虽然复杂点,但效果提升明显。
分层 RAG 也不是终点,还有很多可以探索的方向:
- 用 LLM 自动生成文档摘要作为更高层级的节点
- 引入知识图谱,记录章节间的引用关系
- 根据用户反馈动态调整检索策略
如果你也在做文档问答系统,遇到类似问题,可以试试分层 RAG。也许不是最优解,但可能会打开新的思路。
相关文章:
版权声明: 本文首发于 指尖魔法屋-从平铺走到分层:AI分层RAG笔记(https://blog.thinkmoon.cn/post/378-ai-hierarchical-rag-flat-hierarchical-practice/) 转载或引用必须申明原指尖魔法屋来源及源地址!
评论
使用 GitHub 账号登录后即可留言,支持 Markdown。