去年我帮朋友做一个产品资料问答系统,需求其实很简单:把几十页内部手册喂给大模型,让一线同事随便问。我们一开始就用了最常规的 RAG,文档切块、向量化、检索、拼 Prompt,跑起来确实快,结果一遇到跨章节的问题就露馅。“A 模块和 B 模块怎么配合”“这次升级解决了上次遗留的哪个问题”,模型要么从某一段里抠出只言片语,要么干脆把不相关的内容缝起来生成一个看着像模像样的答案。这个问题让我开始换一个角度琢磨:检索的本质,到底是在检索什么?是检索“一段文字”,还是检索“一个知识”?
后来真正把一套层级检索的思路跑通,我发现在多数场景里,答案的准确性并不取决于模型有多聪明,而取决于你喂给它的资料组织得有多好。这篇文章就从头讲讲我实践的 HiRAG(层级知识检索增强生成),它和普通 RAG 的关键差异,是把文档从“一堆碎片”变成“三层结构”,再让模型按结构去找答案。思路不复杂,代码也很直白,适合刚接触大模型、想快速把问答落地的小白程序员。
1. 先理解普通 RAG 为什么“不够用”
1.1 大模型的先天短板:它是一场闭卷考试
大模型训练完成后,知识就定格在训练数据那个时间点上了。你问它训练完之后才发布的新产品、新政策、内部文档,它大概率是不知道的。更麻烦的是,它不会老老实实说“我不知道”,而是会顺着你的问题编一个听上去很合理、实际上可能完全错误的内容,这就是常说的“一本正经地胡说八道”。
你可以把模型理解成一个参加过海量考试、但再也没更新过教材的学霸。你问他教材范围内的题,它答得极好;你问它教材里没有的内容,它就只能根据见过的相似题目“猜一个”。所以业界的共识是:要让模型在真实业务里可被信任,与其单纯追求“更大更聪明”,不如先解决“它到底有没有参考材料”的问题。闭卷考试不行,那就给它开卷。
1.2 普通 RAG 干的是什么活
RAG 的全称是 Retrieval-Augmented Generation,检索增强生成。它要做的事用一句话说:在模型回答之前,先从外部知识库里捞一批相关的文字片段,塞进 Prompt 里当参考资料,然后再让模型照着资料回答。
最简单的实现是这样的:
# 离线阶段:建索引 chunks = split_document(doc_text, chunk_size=500, overlap=50) vectors = embed(chunks) vector_db.add(vectors) # 在线阶段:查资料 + 生成 q_vec = embed(query) hits = vector_db.search(q_vec, top_k=10) prompt = f"请根据以下资料回答问题:\n{hits}\n问题:{query}" answer = llm.chat(prompt)这个流程本身没问题,很多线上小工具都是这么跑的。问题出在几个细节上:切块、召回、拼接这三步,每一步都可能丢信息。
1.3 传统方案的三个硬伤
第一个硬伤是切块切断了语义。文档里本来是一个完整段落讲一个逻辑,你按固定字数一刀切,切成几块之后,前后文关系就断了。第二个硬伤是召回的“温差”:向量检索只看字面相似度,很多时候把词面像但意思无关的段落也捞回来了,真正关键的那段反而没进 top-k。第三个硬伤是跨文档、跨章节的多跳问题。比如“这个版本解决了上一个版本遗留的哪个问题”,答案分布在两个章节里,一个切块根本装不下,普通 RAG 就会漏掉信息。
我当时在项目里遇到的就是第三类问题。那段时间我试过加大 top_k、改切块策略、用重排序模型,效果有一些,但都不稳定。后来发现方向得变:别只想怎么“捞得更准”,而是先把知识本身组织出结构,让检索顺着结构走。
2. HiRAG 的层级知识:你不是在找碎片,是在查地图
2.1 三层结构怎么设计
HiRAG 的核心,是把知识组织成三层,而不是一层:
第一层叫证据层,也就是原始文档切出来的一个个块,保留完整的细节,这是最终要作为参考资料喂给模型的底料。
第二层叫知识点层,每个知识点是一个 50 到 200 字的语义摘要。它既不完全等于原始块,也不是凭空生成,而是把原始块里某个独立的知识点提炼出来。比如原始文档里有一大段说 V2.2 版本修复了调度模块的问题,你可以提炼出“V2.2 引入动态优先级队列,解决超时重试导致的任务堆积”。
第三层叫概念关系层,它把知识点之间的关系连起来。比如“动态优先级队列”这个改进,关联到“V2.0 超时重试问题”;“日志接口优化”关联到“告警延迟”。这一层像一张小地图,让你能从“问题 A”走到“解决方案 B”。
我建议你用图书馆来理解这个结构:概念关系层是图书馆里的楼层导览图,告诉你“计算机类在三楼东侧”;知识点层是图书卡片摘要,让你快速判断这本书值不值得看;证据层才是书架上的原书,是最终的引用来源。检索顺序也如出一辙:先看导览图,再翻摘要卡,最后取原书。
2.2 从文档到三层结构,数据是怎么流的
建库的时候,切块只是第一步。你还需要在切块之后做两件额外的事:摘要提炼和关系抽取。
摘要提炼,可以理解为对每一块文档让大模型“读一遍再浓缩”,并且强调一句话规则:一个知识点只讲一件事。别指望一次提炼就完美,宁可一个块拆出三五个知识点,也别揉成一个四不像。关系抽取则要从已经生成的知识点之间找到连接,比如“A 引入了 X,解决了 B 的问题”“C 依赖 D”“E 替代了 F”。
这部分如果完全靠人工做,规模一大就崩溃。常规做法是让大模型批量抽取,你把抽取 Prompt 写得足够明确,输出 JSON 格式,比如:
{ "knowledge_points": [ {"id": "K001", "text": "V2.2 引入动态优先级队列,解决超时重试导致任务堆积", "source_chunk": "chunk_18"} ], "relations": [ {"from": "K001", "to": "K002", "type": "solves"}, {"from": "K003", "to": "K001", "type": "related_to"} ] }这一层抽取质量直接决定 HiRAG 的上限。如果模型把关系抽错了,后续检索再准也救不回来。我自己做过几次实验,结论是:不要省这一步,但也不要指望一步到位,先跑一个小样本,人工盯一遍,再放量。这个钱和精力不能省。
2.3 查询是怎么“一层一层”找到答案的
普通 RAG 是一步到位:query 向量化,直接到向量库里搜 top_k。HiRAG 则是多步走:
第一步,把用户 query 向量化,先在概念关系层里找最相关的几个节点。这一步不需要精确,只需要定位“这个问题大概在知识地图的哪个区域”。
第二步,顺着命中的概念节点,找到它关联的知识点。因为知识点是语义摘要,信息密度比原始块高,所以这一步能快速锁到“哪几条知识真正和问题相关”。
第三步,从命中的知识点下钻到它们的原始证据块。这一层才把完整的细节拿出来,拼成给模型的上下文。
第四步,合并去重、按相关度排序,拼 Prompt,交给生成模型。
这样设计的好处是,它把“模糊的大范围匹配”和“精确的证据定位”拆开了。你看,普通 RAG 最大的毛病就是一次性在全库里做向量相似度匹配,而向量相似度经常被字面相近的内容干扰。HiRAG 先在地图层面上定位目标——就像你想找小区里的某栋楼,先在地图上定位到“这个小区”,再进楼门,而不是站在原地靠感觉朝所有门牌号扫射。
3. 手把手跑通一个 HiRAG 最小实现
3.1 准备工作:一份测试文档和一个模型接口
动手之前先准备三件事:一份你熟悉的文档、一个可用的模型接口、一个向量数据库。
文档我建议用产品手册、项目周报、制度文件这类信息密度高、有明显“知识点”的文本。模型接口不用最贵,能用聊天补全和 Embedding 就行。向量数据库用常见的开源方案就可以,本地起一个,几行代码能连上。先别纠结性能,最小实现跑通了再优化。
另外我强烈建议你准备一个测试问题集,至少十个问题,覆盖单点查询、跨章节查询、对比查询这三类。没有评测集,你改完代码都不知道自己是改好了还是改坏了。这是我踩过最深的坑,没有之一。
3.2 建立一个最简公共调用层
下面这段代码把 Embedding 和 Chat 调用封装好,后面所有流程都复用。用 HTTP 接口演示,方便你替换成自己手头的服务:
import requests import numpy as np from typing import List, Dict EMBED_URL = "http://your-service/v1/embeddings" CHAT_URL = "http://your-service/v1/chat/completions" EMBED_MODEL = "your-embedding-model" CHAT_MODEL = "your-chat-model" def get_embedding(text: str) -> List[float]: resp = requests.post(EMBED_URL, json={ "model": EMBED_MODEL, "input": text }, timeout=30) resp.raise_for_status() return resp.json()["data"][0]["embedding"] def chat(prompt: str) -> str: resp = requests.post(CHAT_URL, json={ "model": CHAT_MODEL, "messages": [{"role": "user", "content": prompt}] }, timeout=60) resp.raise_for_status() return resp.json()["choices"][0]["message"]["content"]这层封装不是重点,重点是后面怎么组织数据。
3.3 从文档到三层索引的关键代码
实际建库时,我会分三个步骤跑。第一步,文档切块;第二步,抽取知识点和关系;第三步,建立三层索引并写入向量库。
切块我这里用的是递归字符切法,先按段落切,段落太长再按句子边界切。块大小我控制在 400 到 800 字,重叠 80 到 120 字。块太大会把不同主题混在一起,块太小会截断句子语义,400 到 800 是个比较稳的经验区间。
def split_document(text: str, chunk_size=600, overlap=120) -> List[str]: # 省略细节:按段落和标点边界递归切分 # 核心是要保证每个 chunk 语义相对完整 return chunks第二步是让模型抽知识点和关系。这里的关键是 Prompt 要非常具体。我会让模型这样输出 JSON:
EXTRACT_PROMPT = """ 请阅读下面文档片段,提取其中的知识点和知识点之间的关系。 要求: 1. 每个知识点只能描述一个独立含义,长度 50~200 字 2. 知识点要保留关键实体和具体数据 3. 关系类型仅限:solves(解决), relates_to(相关), depends_on(依赖), replaces(替代) 输出 JSON 格式: { "knowledge_points": [{"id": "K001", "text": "..."}], "relations": [{"from": "K001", "to": "K002", "type": "solves"}] } 文档片段: {chunk} """ def extract_knowledge(chunk: str) -> Dict: prompt = EXTRACT_PROMPT.format(chunk=chunk) result = chat(prompt) # 解析 JSON,做一层异常兜底 return load_json_with_fallback(result)第三步,把数据写入索引。这里我建两张表,一张是原始 chunk 的向量索引,另一张是知识点的向量索引。关系用一张普通的映射表保存就够了:
class HiRAGIndex: def __init__(self): self.chunk_store = {} # chunk_id -> text self.kp_store = {} # kp_id -> {"text": str, "chunk_id": str} self.kp_vectors = [] # 知识点向量列表,简化存储 self.relation_map = {} # kp_id -> [关联的 kp_id] def add_document(self, chunks: List[str]): # 第 1 步:存原始 chunk,并向量化 chunk_ids = [] for chunk in chunks: cid = f"chunk_{len(self.chunk_store)}" self.chunk_store[cid] = chunk chunk_ids.append(cid) # 第 2 步:每个 chunk 抽知识点 for cid in chunk_ids: parsed = extract_knowledge(self.chunk_store[cid]) for kp in parsed["knowledge_points"]: kp_id = kp["id"] self.kp_store[kp_id] = { "text": kp["text"], "chunk_id": cid } self.kp_vectors.append(get_embedding(kp["text"])) # 第 3 步:记录关系 for rel in parsed["relations"]: self.relation_map.setdefault(rel["from"], []).append(rel["to"]) self.relation_map.setdefault(rel["to"], []).append(rel["from"])上线之前,记得把 kp_vectors 换成真正的向量数据库,并给每一个向量加上 kp_id 元数据。这个最小实现的重点是让你理解流程,而不是造一个高并发系统。
3.4 分层检索的主逻辑
检索时最核心的是“先概念、再下钻”的顺序:
def search(self, query: str, top_k=5, max_chunks=8) -> List[str]: q_vec = get_embedding(query) # 第一跳:在知识点层找最相关的 top_k kp_hits = find_top_k(self.kp_vectors, q_vec, top_k) # 第二跳:根据知识点关联关系,扩展相关知识点 expanded_kp = set(kp_hits) for kp_id in kp_hits: for rel in self.relation_map.get(kp_id, []): expanded_kp.add(rel) # 第三跳:从知识点下钻到原始 chunk chunk_ids = set() for kp_id in expanded_kp: chunk_ids.add(self.kp_store[kp_id]["chunk_id"]) # 按顺序拼装证据,控制上下文长度 ranked_chunks = [] for cid in chunk_ids[:max_chunks]: ranked_chunks.append(self.chunk_store[cid]) return ranked_chunks这里值得解释几个细节。
find_top_k只是一个向量和知识点的余弦相似度排序。如果后续性能吃紧,你可以在这里换任何常用向量检索库,逻辑不变。
第二跳为什么要做关系扩展?因为用户问题里的关键词可能只命中了一个知识点,而这个知识点关联的另一个知识点可能是答案的另一半。比如用户问“V2.2 为什么解决了任务堆积”,可能命中了知识点“引入动态优先级队列”,但“V2.0 存在超时重试机制”这个知识点也通过solves关系挂在一起,你不扩展就丢了上下文。
第三跳为什么要下钻到原始 chunk,而不是直接用知识点文本拼 Prompt?因为知识点是摘要,缺少实现细节、参数、表格、例子。真正要让模型生成靠谱答案,必须把原始原文拿出来。当然,这也会带来一点上下文长度压力,所以max_chunks要控制住。我一般限制在 4 到 10 个块,总长度控制在 3000 字左右,效果和成本比较平衡。
3.5 生成答案:把检索结果交给模型
最后一步很直白,把上下文和问题拼好,交给 chat 模型。但 Prompt 写法有一点讲究:
def answer(self, query: str) -> str: docs = self.search(query, top_k=5, max_chunks=8) context = "\n\n".join(docs) prompt = f""" 你是知识库问答助手。请只依据以下资料回答问题,资料不足时明确说“资料不足”。 资料: {context} 问题: {query} """ return chat(prompt)注意 Prompt 里那句“资料不足时明确说不懂”,这句话非常关键。它把模型的“硬要回答”的冲动往下压了压,我实测之后发现,回答里凭空编造的比例明显降低。这不是什么高级技巧,但很多人忽略它,导致 RAG 上线后效果被幻觉拖垮。
4. 踩坑清单:从“能跑”到“能用”的几个坎
4.1 分层抽取出错:重复、遗漏、关系乱
我最早跑通 HiRAG 后,测出来的结果并不理想,后来一查,问题不在检索,在建库阶段就坏了。
最常见的坑是知识点重复:同一件事在两个不同的 chunk 里各抽了一遍,导致检索 return 一堆相似内容,浪费上下文窗口。解决办法是加一层合并策略:按命名实体和数据做相似度去重,或者在建库 Prompt 里加一句“如果本片段和已有知识点表示同一件事,就只输出关系,不输出新知识点”。设一个阈值,相似度高于 0.85 的视为重复。
另一个坑是关系抽取太“虚”。我把 Prompt 限制死了关系类型,只允许 solves、relates_to、depends_on、replaces 这四种。最开始我允许模型自由发挥,结果它抽出来“is_about”“causes”“describes”十几种类型,你后面根本没法过滤。限制关系类型会让你损失一些灵活性,但换来的是稳定和可维护,对小项目尤其是这样。
4.2 检索噪音:该下钻到哪一层,要有判断
HiRAG 不是每一层都要从头跑到尾。如果你的问题很简单,“耗材的质保期是多久”,直接在第一层向量库里检索就行,没必要做两级下钻,那样反而慢。
真正的分层检索更适合“多跳问题”和“概念型问题”。我的经验是:可以先在概念层跑一次,如果概念层的 Top1 到 Top3 得分特别集中,再用扩展下钻;如果概念层本身的得分就很散,说明 query 太泛,这时候不如直接回退到普通 RAG,在证据层海选更多块。也就是说,HiRAG 最好的用法,是作为多个检索策略里的一个分支,而不是全部的替代品。
4.3 性能慢、索引膨胀
三层结构的信息密度高,数据量会比普通 RAG 多出不少。几十万字的知识库还好,如果是千万字级别,离线构建可能要按小时算。性能优化的方向有两个:一是异步流水线,切块、抽取、向量化用队列并行跑;二是只对知识点层使用模型抽取,证据层保持原始文本。还有个容易被忽略的点:Embedding 调用次数会飙升,最好批量调用,一次传几十条文本进去,接口成本会直线下降。
4.4 别忽略评测
这个真得单独拎出来说。我自己早期做 RAG 项目的时候,总觉得“效果好一点”“看着挺准”就够了,结果没有评测集,改完代码完全不知道改动是正向还是负向。
HiRAG 因为多了一层抽取,改动的变量比普通 RAG 多,更需要一个高质量评测集。我建议从真实用户问题里攒至少 30 条,再准备一个正确的答案摘要。每次改动之后,跑一遍评测集,记录三个指标:命中率(参考文档是否被检索到)、答案正确率、上下文冗余度。有这三个数字,你才知道每一层修改到底把它变好了还是变坏了。
5. 效果对比与适用边界
5.1 小型对比:普通 RAG 和 HiRAG 差在哪
我拿手上的产品手册做了个简单对比,问题集分成三类:事实查询、流程查询、跨章节因果查询。结果不一定代表所有场景,但方向很有参考价值:
| 问题类型 | 普通 RAG 表现 | HiRAG 表现 |
|---|---|---|
| 单点事实查询(参数、日期、数值) | 优秀,直接命中 | 良好,略慢 |
| 多步骤流程查询 | 一般,容易缺步骤 | 良好,能补齐关联知识点 |
| 跨章节因果查询 | 差,经常答错对象 | 好,关系层能把因果接起来 |
| 低资源/冷启动 | 可快速上线 | 需要额外建库成本 |
从表格能看出,HiRAG 不是全面碾压,而是在“复杂问题”上优势明显,在“简单事实查询”上反而有点杀鸡用牛刀。所以选型不是越先进越好,而是看你的知识库复杂度。如果你只有几百条 FAQ、用户问题以查阅为主,普通 RAG 完全够用;如果你有一堆长文档、问题经常跨章节、逻辑链路长,上 HiRAG 就非常值。
5.2 什么情况不要硬上 HiRAG
有几种情况我建议你先别碰 HiRAG。
第一种是文档质量太差。如果你手里的资料满是重复、版本混杂、格式混乱,做分层抽取等于在垃圾上盖楼,出来的层级关系全是错的。先花时间清洗资料,比任何花哨的检索方案都重要。
第二种是交互延迟要求极高。HiRAG 天然多跳,延迟比普通 RAG 高,多一跳就是多一次向量查询,如果还带着关系扩展,耗时会更多。实时对话系统要慎重。
第三种是你连基础 RAG 都还没跑通。这个比较现实,我看到太多人一上来就追求复杂方案,结果连最基础的链路都没调明白。先把普通 RAG 跑起来,再一步步加分层,你会更清楚每一层带来的收益在哪里。
我自己的实践体会是,HiRAG 最大的价值不是“准确率提升几个点”,而是它逼着你把知识库想清楚。建索引、抽知识点、定关系的过程,本质上就是在梳理业务逻辑。这个梳理过程带来的认知提升,比你多调几次模型参数有用得多。如果沿着这个思路继续扩展,后面还可以把关系层接到图数据库上,做更复杂的多跳推理,或者把抽取结果加一层人工审核,做成有监督的知识沉淀流程。但第一步,还是先把你手头那份文档认真分好层。