从平铺走到分层: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

遇到的问题

这个实现在简单场景下还行,但在复杂文档上就露馅了:

  1. 上下文丢失:问题问某个 API 的参数说明,但切分时把 API 定义和参数说明分到了不同 chunk,结果搜出来只有定义没有说明
  2. 粒度不匹配:问"这个系统有哪些模块",返回的是某个模块的具体实现,太细节了;问"如何配置 Redis",返回的又是整个架构文档,太笼统了
  3. 重复内容多:同一个概念在多个 chunk 里重复出现,拼接后浪费 token,还可能干扰 LLM
graph TD A[用户查询] --> B[向量搜索 Top-K chunks] B --> C1[Chunk 1: 部分相关] B --> C2[Chunk 2: 不相关] B --> C3[Chunk 3: 高度相关] B --> C4[Chunk 4: 重复内容] B --> C5[Chunk 5: 背景信息缺失] C1 & C2 & C3 & C4 & C5 --> D[拼接成 prompt] D --> E[LLM 回答] E --> F[结果: 信息噪音大/上下文缺失]

需求:我们真正想要什么

核心痛点

经过几轮测试,我发现问题的本质是:不同问题需要不同粒度的信息,但平铺式 RAG 只能返回固定粒度的 chunk

比如:

  • “这个系统的整体架构是什么?” → 需要章节级、模块级信息
  • “Redis 连接池的配置参数有哪些?” → 需要段落级、代码级信息
  • “如何处理网络超时?” → 可能需要跨多个段落的上下文

期望的效果

理想的 RAG 应该能:

  1. 识别查询意图:判断用户需要什么粒度的信息
  2. 按需检索:根据意图选择合适层级的文档
  3. 保持上下文:确保返回的信息在文档结构上是完整的
  4. 避免重复:不返回重叠或冗余的内容

实现:分层 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

分层检索流程图

graph TD A[用户查询] --> B{查询意图分类} B -->|架构/概览| C[章节级搜索 level=2] B -->|配置/实现| D[段落级搜索 level=4] B -->|不确定/混合| E[多层级搜索 level=2,3,4] C --> F[检索候选节点] D --> F E --> F F --> G{去重与扩展} G --> H[过滤重复内容] G --> I[补充父节点上下文] H --> J[按相关性排序] I --> J J --> K[构建 prompt] K --> L[LLM 生成回答] L --> M[返回结果 + 检索来源]

踩坑:实践中的问题

问题 1:层次解析的可靠性

最初的 Markdown 解析器太简单,遇到不规范的文档就挂了:

# 标题一
内容一

## 标题二

    ### 小标题(前面有空格,解析失败)
内容二

解决方案:使用更健壮的解析器,比如 markdown_itpython-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 消耗850620
平均响应时间1.2s1.8s
用户满意度3.4/54.6/5

典型案例

案例 1:架构问题

问题:“这个系统有哪些主要模块?”

平铺 RAG 回答

系统包含 Redis 模块、消息队列模块、API 网关模块、数据库模块等。Redis 模块用于缓存,配置参数包括…(后面全是 Redis 细节)

分层 RAG 回答

系统包含以下主要模块:

  1. Redis 模块 - 负责缓存管理
  2. 消息队列模块 - 处理异步任务
  3. API 网关模块 - 统一接口入口
  4. 数据库模块 - 持久化存储

案例 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/) 转载或引用必须申明原指尖魔法屋来源及源地址!