跳到主要内容

WeKnora RAG 检索管线:一条按需装配的 12 阶段 Pipeline

· 阅读需 9 分钟
一介布衣
全栈开发者

分片切好了、向量也建好了,接下来的问题是:用户提一个问题,系统怎么从成千上万个块里捞出最相关的那几个,排好序,再拼成 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 其实有两条检索路径:

  1. Chat Pipeline(本文主线):给 RAG Quick Q&A 用,事件驱动、阶段固定装配,一次问答走完整条链。
  2. 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 与三大能力
  • (五·番外)向量库可插拔抽象