WeKnora RAG 检索管线:一条按需装配的 12 阶段 Pipeline
分片切好了、向量也建好了,接下来的问题是:用户提一个问题,系统怎么从成千上万个块里捞出最相关的那几个,排好序,再拼成 prompt 喂给模型?
很多人以为这一步就是「算个 query 向量、查一次向量库、取 Top-K」。真做过就知道,这条链路里藏着一堆工程细节:多轮对话的历史怎么带、查询要不要改写、向量和关键词怎么融合、召回一堆重复的怎么办、怎么保证结果既有相关性又有多样性。WeKnora 把这些环节抽象成了一条事件驱动、按需装配的 Pipeline。这是系列第三篇。
PipelineBuilder:管线不是写死的
WeKnora 的检索管线定义在 internal/application/service/chat_pipeline/ 包,每个阶段是一个独立的 plugin 文件。真正有意思的是它的装配方式——管线不是硬编码的固定流程,而是用 PipelineBuilder 按请求特征动态拼装出来的。
session_knowledge_qa.go 里的装配逻辑一目了然:
var pipeline []types.EventType
if !needsRAG {
// 纯聊天 —— 不需要检索
pipeline = types.NewPipelineBuilder().
AddIf(hasHistory, types.LOAD_HISTORY).
Add(types.MEMORY_RECALL).
Add(types.CHAT_COMPLETION_STREAM).
Build()
} else {
// RAG —— 按特性开关动态装配
pipeline = types.NewPipelineBuilder().
AddIf(hasHistory, types.LOAD_HISTORY).
Add(types.MEMORY_RECALL).
Add(types.QUERY_UNDERSTAND).
Add(types.CHUNK_SEARCH_PARALLEL).
Add(types.CHUNK_RERANK).
AddIf(webSearchEnabled, types.WEB_FETCH).
Add(types.CHUNK_MERGE).
Add(types.FILTER_TOP_K).
AddIf(chatManage.DataAnalysisEnabled, types.DATA_ANALYSIS).
Add(types.INTO_CHAT_MESSAGE).
Add(types.CHAT_COMPLETION_STREAM).
Build()
}
两个方法值得注意:Add 是无条件加阶段,AddIf 是按条件加。有没有历史对话、开没开网络搜索、要不要数据分析,都会让最终管线长得不一样。一个纯闲聊的请求只走 3 个阶段,一个完整的 RAG 请求可能走 11 个阶段。
这种设计的好处是:每个阶段都是一个实现了统一接口的 plugin,通过 ActivationEvents() 声明自己响应哪个事件,管线只负责按顺序广播事件。加一个新阶段不用改老代码,删一个阶段也不影响其它——这是典型的责任链 + 事件驱动组合。
完整 RAG 管线的阶段链
把上面那条 RAG 管线画出来,就是一次问答的完整生命周期:
flowchart TD
Q["用户提问"] --> H["LOAD_HISTORY<br/>加载多轮历史"]
H --> M["MEMORY_RECALL<br/>长期记忆召回"]
M --> QU["QUERY_UNDERSTAND<br/>查询理解/改写/扩展"]
QU --> SP["CHUNK_SEARCH_PARALLEL<br/>向量+BM25+图谱 并发检索"]
SP --> RR["CHUNK_RERANK<br/>重排模型打分"]
RR --> WF["WEB_FETCH<br/>(可选)网络补充"]
WF --> MG["CHUNK_MERGE<br/>去重/扩展/FAQ/父子合并"]
MG --> TK["FILTER_TOP_K<br/>Top-K 过滤 + 多样性"]
TK --> DA["DATA_ANALYSIS<br/>(可选)数据分析"]
DA --> IM["INTO_CHAT_MESSAGE<br/>拼装 prompt"]
IM --> CC["CHAT_COMPLETION_STREAM<br/>LLM 流式生成"]
CC --> A["带引用的回答"]
阶段常量定义在 internal/types/chat_manage.go 的 EventType 里,一共十来个。下面挑几个关键的展开讲。
QUERY_UNDERSTAND:先搞懂用户在问什么
直接拿用户的原始 query 去检索,效果往往不好——尤其是多轮对话里,用户第二句「那它的价格呢?」单独拿去查向量库根本不知道「它」指什么。
query_understand.go + query_expansion.go 负责这一步:结合对话历史把指代补全、把口语化的问题改写成更适合检索的形式,必要时做查询扩展(生成多个语义相近的查询变体一起召回)。改写用的 prompt 来自配置 RewritePromptSystem / RewritePromptUser。这一步做好了,后面检索的召回率能明显提升。
CHUNK_SEARCH_PARALLEL:混合检索并发跑
这是整条管线的召回核心。search_parallel.go 的注释写得很清楚:它会并发跑 chunk 检索和 entity 检索。所谓混合检索(hybrid),是同时走两条路:
- 向量检索(Dense):把 query 嵌入成向量,在向量库里做相似度召回,擅长语义匹配。
- 关键词检索(BM25 Sparse):传统倒排索引,擅长精确的术语、专有名词、编号匹配。
两条路的结果后面会在 merge 阶段融合。为什么非要混合?因为向量检索对「精确术语」不敏感(比如型号 A100-80G、错误码 ORA-00600),而 BM25 对「语义相近但用词不同」无能为力(比如「怎么退款」vs「退货流程」)。两者互补,召回质量才稳。
如果知识库开了图谱(GraphRAG),search_entity.go + extract_entity.go 还会并发做实体检索,从知识图谱里捞相关实体和关系作为补充证据。
底层的检索能力通过 RetrieveEngine 接口(internal/types/interfaces/retriever.go)抽象,所以同一套管线可以跑在 pgvector、Elasticsearch、Milvus、Qdrant 等 8 种向量库上。当一个工作区配了多个存储实例时,knowledgebase_search_fanout.go 会把查询 fan-out 到多个 store 并发执行再汇总。
CHUNK_RERANK:粗排之后再来一次精排
混合检索召回的候选往往有几十个,质量参差不齐。向量相似度是「粗排」,快但不够准。rerank.go 这一步用一个专门的 rerank 模型(cross-encoder 类)对「query + 每个候选块」做精细打分,重新排序。
rerank 模型和 embedding 模型的区别在于:embedding 是把 query 和 doc 分别编码再算距离,快但交互浅;rerank 是把两者拼在一起过模型,能捕捉更细的交互,准但慢。所以工程上的标准做法是「embedding 粗召回一批 → rerank 精排 Top-N」,用两段式平衡速度和质量。WeKnora 的 RerankTopK 和 RerankThreshold 就是控制这一步留多少、卡多严。
CHUNK_MERGE:把多路结果拧成一股
到这一步,手上有好几路召回结果:向量的、BM25 的、图谱的、可能还有网络抓取的。merge.go 及其几个变体负责把它们融合成一份干净的候选列表:
merge_overlap.go:处理重叠——分片时相邻块本来就有 overlap,同一个内容可能被子块和父块同时召回,要去重。merge_expand.go:父子分块的「回捞」——命中的是子块,但要把它对应的父块内容展开进来,给模型更完整的上下文。merge_faq.go:FAQ 类型的结果单独处理。merge_history.go:融合历史相关结果。
融合后还会做一次 deduplicate(去重),避免同一块内容因为多路召回而重复出现在 prompt 里,白白浪费 token。
FILTER_TOP_K:相关性和多样性的平衡
merge 之后候选还是偏多,filter_top_k.go 做最后的收敛,取 Top-K 喂给模型。这里有个容易被忽略的点:不能只按分数取前 K 个,否则可能全是高度相似的内容——比如一个问题召回了 5 个几乎讲同一件事的块,挤掉了其它角度的信息。
WeKnora 在 Agent 检索工具 search_knowledge.go 里用了经典的 MMR(Maximal Marginal Relevance) 来做多样性控制:
// applyMMR applies Maximal Marginal Relevance to reduce redundancy.
mmr := lambda*r.Score - (1.0-lambda)*maxRedundancy[i]
lambda 取 0.7,意思是 70% 权重给相关性、30% 给「和已选结果的差异度」。每选一个块,都要在「它自己够相关」和「它跟已经选中的块不重复」之间做权衡。这样最终的 Top-K 既相关又覆盖面广,比单纯按分数截断的效果好。
INTO_CHAT_MESSAGE 与 CHAT_COMPLETION:拼装与生成
into_chat_message.go 把筛选好的块拼进 prompt:组装系统提示、把检索到的内容作为上下文、带上引用标记(这样模型回答时能标注来源)、附上附件内容和用户问题。references.go 负责维护引用信息,让前端能渲染出「这句话来自哪个文档的哪一段」的引用浮层。
最后 chat_completion_stream.go 调 LLM 做流式生成,边生成边通过事件推给前端。整个管线每个阶段都有独立的 span 埋点,配合 Langfuse 能看到完整的 trace——哪个阶段耗时多久、召回了多少、rerank 后剩多少,一目了然。这也是为什么 WeKnora 敢说自己「可观测」:RAG 管线的每一步都是可追踪的。
两条检索路径:Chat Pipeline vs Agent Tool
需要区分的是,WeKnora 其实有两条检索路径:
- Chat Pipeline(本文主线):给 RAG Quick Q&A 用,事件驱动、阶段固定装配,一次问答走完整条链。
- Agent Tool
search_knowledge:给 ReAct Agent 用,是 Agent 可以反复调用的一个工具。它内部也做 hybrid(hybrid/semantic/keyword三种模式)+ 去重 + rerank + MMR(0.7),但由 Agent 自主决定调几次、用什么参数。
前者是「系统替你编排好的一次性管线」,后者是「Agent 手里可以反复挥舞的检索武器」。这也是 RAG 和 Agent 两种范式的本质区别——下一篇会展开讲 Agent 这条线。
小结
WeKnora 的检索管线给我最大的启发有两点:
- 管线是装配出来的,不是写死的。用 PipelineBuilder + 事件驱动 plugin,让「要不要网络搜索」「有没有历史」这些差异变成阶段的增删,而不是散落各处的 if-else。
- 召回是分层收敛的。查询改写 → 混合并发召回(向量+BM25+图谱)→ rerank 精排 → merge 融合去重 → MMR 兼顾多样性 → Top-K,每一步都在缩小范围、提升质量。这条「漏斗」正是生产级 RAG 和 demo 级 RAG 的分水岭。
到这里,文档从解析、分片到被检索出来的完整链路就打通了。最后一篇,我们往上走一层,看 WeKnora 怎么在检索之上搭出一个能自主推理、会用工具、还能自己写 Wiki 的 ReAct Agent。
系列导航:
- (一)架构全景:Go 与 Python 双引擎
- (二)自适应分片机制:三层策略与父子分块
- (三)RAG 检索管线:12 阶段 Pipeline 剖析 ← 本文
- (四)ReAct Agent 与三大能力
- (五·番外)向量库可插拔抽象
