引言
在 RAG 系统中,Chunk 的质量只解决了一半问题。
另一半更棘手的问题是:
在一堆“看起来都相关”的 Chunk 里,究竟哪些才值得被送进 LLM?
真实工程场景中,检索阶段面临的从来不是“有没有结果”,而是:
- 召回的 Chunk 数量过多,噪声极高
- 不同来源(向量、关键词、FAQ、Web)的结果难以统一
- rerank 一旦阈值设高,直接“全军覆没”
- 阈值设低,又会把大量低价值 Chunk 一起塞进上下文
- 多段 Chunk 来自同一文档,却彼此高度重复
这些问题,并不能靠一个更大的模型解决。
在上一篇文章中,我们已经分析了 WeKnora 如何构建结构完整、语义稳定的高质量 Chunk。而本文关注的是紧随其后的关键一步:
当 Chunk 已经准备好之后,WeKnora 是如何在检索与重排阶段,筛选出“真正可用”的那一小部分?
本文将基于源码,重点拆解 WeKnora 在 RAG 查询过程中围绕以下问题的工程实现:
- 多路召回(向量 / 关键词 / FAQ / Web)如何并行执行并统一结果
- rerank 在“选不出结果”时如何优雅降级
- FAQ、历史引用为何拥有更高优先级
- MMR 与位置先验是如何减少重复 Chunk 的
- Chunk 如何从“检索结果”一步步演化为最终上下文
本文会专注于检索、重排与 Chunk 构建这一条最影响 RAG 实际效果的工程链路。
Pipline
WeKnora 检索召回采用 Pipline 的设计,在 WeKnora 中,一共包含以下 4 个预定义的 Pipline。
var Pipline = map[string][]EventType{
"chat": { // Simple chat without retrieval
CHAT_COMPLETION,
},
"chat_stream": { // Streaming chat without retrieval (no history)
CHAT_COMPLETION_STREAM,
STREAM_FILTER,
},
"chat_history_stream": { // Streaming chat with conversation history
LOAD_HISTORY,
CHAT_COMPLETION_STREAM,
STREAM_FILTER,
},
"rag": { // Retrieval Augmented Generation
CHUNK_SEARCH,
CHUNK_RERANK,
CHUNK_MERGE,
INTO_CHAT_MESSAGE,
CHAT_COMPLETION,
},
"rag_stream": { // Streaming Retrieval Augmented Generation
REWRITE_QUERY,
CHUNK_SEARCH_PARALLEL, // Parallel: CHUNK_SEARCH + ENTITY_SEARCH
CHUNK_RERANK,
CHUNK_MERGE,
FILTER_TOP_K,
DATA_ANALYSIS,
INTO_CHAT_MESSAGE,
CHAT_COMPLETION_STREAM,
STREAM_FILTER,
},
}
以及一些自由组合的 Pipline,如:/knowledge-search 接口对应的实现纯检索的 Pipline 为:
searchEvents := []types.EventType{
types.CHUNK_SEARCH, // Vector search
types.CHUNK_RERANK, // Rerank search results
types.CHUNK_MERGE, // Merge search results
types.FILTER_TOP_K, // Filter top K results
}
通过预定义的 Pipline 可以看出 Weknora 将整个传统 RAG 中的 chat retrieval 过程拆分成了多个步骤。
- CHAT_COMPLETION 普通聊天
- CHAT_COMPLETION_STREAM 流式聊天
- STREAM_FILTER 流式过滤
- LOAD_HISTORY 加载历史记录
- REWRITE_QUERY 查询重写
- CHUNK_SEARCH_PARALLEL 多路检索(文档检索 + 实体检索)
- CHUNK_RERANK 检索重排
- CHUNK_MERGE 合并结果
- FILTER_TOP_K 过滤 top K 结果
- DATA_ANALYSIS 数据分析
- INTO_CHAT_MESSAGE 构建最终消息体
Chat 的相关步骤这里不做拆解,重点拆解 rag_stream Pipline 中的所有步骤。
查询重写
查询重写事件会触发两个插件:
- PluginRewrite 插件,对问题结合会话上下文进行重写
func (p *PluginRewrite) ActivationEvents() []types.EventType {
return []types.EventType{types.REWRITE_QUERY}
}
- PluginExtractEntity 插件,对问题中的实体进行提取
func (p *PluginExtractEntity) ActivationEvents() []types.EventType {
return []types.EventType{types.REWRITE_QUERY}
}
PluginRewrite 插件
触发条件为 EnableRewrite 为 true, 否则直接跳过。默认配置在 config.yaml 中,默认值为 true。
if !chatManage.EnableRewrite {
pipelineInfo(ctx, "Rewrite", "skip", map[string]interface{}{
"session_id": chatManage.SessionID,
"reason": "rewrite_disabled",
})
return next()
}
根据 seesion id 获取历史对话记录。
history, err := p.messageService.GetRecentMessagesBySession(ctx, chatManage.SessionID, 20)
若该 session id 没有历史对话记录,则直接跳过查询重写。
if len(historyList) == 0 {
pipelineInfo(ctx, "Rewrite", "skip", map[string]interface{}{
"session_id": chatManage.SessionID,
"reason": "empty_history",
})
return next()
}
若存在历史对话记录,则对历史对话记录做处理,如:消息格式化,清除无效对话,按时间排序,保留指定轮数对话记录等。
// 格式化对话记录
for _, message := range history {
history, ok := historyMap[message.RequestID]
if !ok {
history = &types.History{}
}
if message.Role == "user" {
// User message as query
history.Query = message.Content
history.CreateAt = message.CreatedAt
} else {
// System message as answer, while removing thinking process
history.Answer = reg.ReplaceAllString(message.Content, "")
history.KnowledgeReferences = message.KnowledgeReferences
}
historyMap[message.RequestID] = history
}
// 清除无效对话记录
for _, history := range historyMap {
if history.Answer != "" && history.Query != "" {
historyList = append(historyList, history)
}
}
// 按时间排序,保留最近的对话记录
sort.Slice(historyList, func(i, j int) bool {
return historyList[i].CreateAt.After(historyList[j].CreateAt)
})
// 保留指定轮数对话记录
if len(historyList) > maxRounds {
historyList = historyList[:maxRounds]
}
将对话记录加入到上下文中,rewrite prompt 中最重要的是基于历史对话记录对代词进行明确或补足,使用 LLM 对查询进行重写,对应 prompt 如下:
rewrite_prompt_system: |
你是一个专注于指代消解和省略补全的智能助手,你的任务是根据历史对话上下文,清晰识别用户问题中的代词并替换为明确的主语,同时补全省略的关键信息。
## 改写目标
请根据历史对话,对当前用户问题进行改写,目标是:
- 进行指代消解,将"它"、"这个"、"那个"、"他"、"她"、"它们"、"他们"、"她们"等代词替换为明确的主语
- 补全省略的关键信息,确保问题语义完整
- 保持问题的原始含义和表达方式不变
- 改写后必须也是一个问题
- 改写后的问题字数控制在30字以内
- 仅输出改写后的问题,不要输出任何解释,更不要尝试回答该问题,后面有其他助手回去解答此问题
## Few-shot示例
示例1:
历史对话:
用户: 微信支付有哪些功能?
助手: 微信支付的主要功能包括转账、付款码、收款、信用卡还款等多种支付服务。
用户问题: 它的安全性
改写后: 微信支付的安全性
示例2:
历史对话:
用户: 苹果手机电池不耐用怎么办?
助手: 您可以通过降低屏幕亮度、关闭后台应用和定期更新系统来延长电池寿命。
用户问题: 这样会影响使用体验吗?
改写后: 降低屏幕亮度和关闭后台应用是否影响使用体验
示例3:
历史对话:
用户: 如何制作红烧肉?
助手: 红烧肉的制作需要先将肉块焯水,然后加入酱油、糖等调料慢炖。
用户问题: 需要炖多久?
改写后: 红烧肉需要炖多久
示例4:
历史对话:
用户: 北京到上海的高铁票价是多少?
助手: 北京到上海的高铁票价根据车次和座位类型不同,二等座约为553元,一等座约为933元。
用户问题: 时间呢?
改写后: 北京到上海的高铁时长
示例5:
历史对话:
用户: 如何注册微信账号?
助手: 注册微信账号需要下载微信APP,输入手机号,接收验证码,然后设置昵称和密码。
用户问题: 国外手机号可以吗?
改写后: 国外手机号是否可以注册微信账号
rewrite_prompt_user: |
## 历史对话背景
{{conversation}}
## 需要改写的用户问题
{{query}}
## 改写后的问题
PluginExtractEntity 插件
通过 LLM 对查询进行实体提取,对应 prompt 如下:
description: |
请基于用户给的问题,按以下步骤处理关键信息提取任务:
1. 梳理逻辑关联:首先完整分析文本内容,明确其核心逻辑关系,并简要标注该核心逻辑类型;
2. 提取关键实体:围绕梳理出的逻辑关系,精准提取文本中的关键信息并归类为明确实体,确保不遗漏核心信息、不添加冗余内容;
3. 排序实体优先级:按实体与文本核心主题的关联紧密程度排序,优先呈现对理解文本主旨最重要的实体;
examples:
- text: "《红楼梦》,又名《石头记》,是清代作家曹雪芹创作的中国古典四大名著之一,被誉为中国封建社会的百科全书。"
node:
- name: "红楼梦"
- name: "曹雪芹"
- name: "中国古典四大名著"
解析 LLM 输出,将实体提取结果转换为结构体
for _, group := range matchData {
switch {
case group[f.nodePrefix] != nil:
attributes := make([]string, 0)
attributesKey := f.nodePrefix + f.attributeSuffix
if attr, ok := group[attributesKey].([]interface{}); ok {
for _, v := range attr {
attributes = append(attributes, fmt.Sprintf("%v", v))
}
}
nodes = append(nodes, &types.GraphNode{
Name: fmt.Sprintf("%v", group[f.nodePrefix]),
Attributes: attributes,
})
case group[f.relationSource] != nil && group[f.relationTarget] != nil:
relations = append(relations, &types.GraphRelation{
Node1: fmt.Sprintf("%v", group[f.relationSource]),
Node2: fmt.Sprintf("%v", group[f.relationTarget]),
Type: fmt.Sprintf("%v", group[f.relationPrefix]),
})
default:
logger.Warnf(ctx, "Unsupported graph group: %v", group)
continue
}
}
graph := &types.GraphData{
Node: nodes,
Relation: relations,
}
多路检索
多路检索包含混合检索,知识图谱检索两部分,这两部分并行进行检索。混合检索包含 Web 搜索和知识库向量检索以及 BM25。
// Goroutine 1: 混合检索
go func() {
defer wg.Done()
err := p.searchPlugin.OnEvent(ctx, types.CHUNK_SEARCH, &chunkChatManage, func() *PluginError {
return nil
})
if err != nil && err != ErrSearchNothing {
mu.Lock()
chunkSearchErr = err
mu.Unlock()
}
pipelineInfo(ctx, "SearchParallel", "chunk_search_done", map[string]interface{}{
"result_count": len(chunkChatManage.SearchResult),
"has_error": err != nil && err != ErrSearchNothing,
})
}()
// Goroutine 2: 知识图谱检索
go func() {
defer wg.Done()
if len(chatManage.Entity) == 0 {
pipelineInfo(ctx, "SearchParallel", "entity_search_skip", map[string]interface{}{
"reason": "no_entities",
})
return
}
err := p.searchEntityPlugin.OnEvent(ctx, types.ENTITY_SEARCH, &entityChatManage, func() *PluginError {
return nil
})
if err != nil && err != ErrSearchNothing {
mu.Lock()
entitySearchErr = err
mu.Unlock()
}
pipelineInfo(ctx, "SearchParallel", "entity_search_done", map[string]interface{}{
"result_count": len(entityChatManage.SearchResult),
"has_error": err != nil && err != ErrSearchNothing,
})
}()
混合检索
前置检查
检查是否有搜索目标,如指定知识库,指定知识文档等,是否开启 Web 检索。
hasKBTargets := len(chatManage.SearchTargets) > 0 || len(chatManage.KnowledgeBaseIDs) > 0 || len(chatManage.KnowledgeIDs) > 0
if !hasKBTargets && !chatManage.WebSearchEnabled {
pipelineError(ctx, "Search", "kb_not_found", map[string]interface{}{
"session_id": chatManage.SessionID,
})
return nil
}
若开启 Web 检索,则知识库检索和 Web 检索并行进行。
// Goroutine 1: 知识库检索
go func() {
defer wg.Done()
kbResults := p.searchByTargets(ctx, chatManage)
if len(kbResults) > 0 {
mu.Lock()
allResults = append(allResults, kbResults...)
mu.Unlock()
}
}()
// Goroutine 2: Web 检索
go func() {
defer wg.Done()
webResults := p.searchWebIfEnabled(ctx, chatManage)
if len(webResults) > 0 {
mu.Lock()
allResults = append(allResults, webResults...)
mu.Unlock()
}
}()
知识库检索
go func() {
defer wg.Done()
kbResults := p.searchByTargets(ctx, chatManage)
if len(kbResults) > 0 {
mu.Lock()
allResults = append(allResults, kbResults...)
mu.Unlock()
}
}()
小文件直接加载策略
遍历每个 knowledgeID 获取对应 chunks,若累计 chunks 数量小于 50 则直接加入 allChunks,若当前 chunks 总数 + 新文件 chunks 数 > 50 则跳过此文件,并记录该文件 id 至 skippedIDs, 最终输出 allChunks 和 skippedIDs。
// 50 chunks * ~500 chars/chunk ~= 25k chars
const maxTotalChunks = 50
for _, kid := range knowledgeIDs {
// Optimization: Check chunk count first if possible?
chunks, err := p.chunkService.ListChunksByKnowledgeID(ctx, kid)
if err != nil {
logger.Warnf(ctx, "DirectLoad: Failed to list chunks for knowledge %s: %v", kid, err)
skippedIDs = append(skippedIDs, kid)
continue
}
if len(allChunks)+len(chunks) > maxTotalChunks {
logger.Infof(ctx, "DirectLoad: Skipped knowledge %s due to size limit (%d + %d > %d)",
kid, len(allChunks), len(chunks), maxTotalChunks)
skippedIDs = append(skippedIDs, kid)
continue
}
allChunks = append(allChunks, chunks...)
loadedKnowledgeIDs[kid] = true
}
向量+关键词混合检索
WeKnora 支持文档切分后的文本块向量化以及用户手动输入的 FAQ 问答对向量化。
- 对文本块内容进行向量检索 + 关键词检索。
- 对 FAQ 问答对进行向量检索。
// 向量检索构建
vectorParams := types.RetrieveParams{
Query: params.QueryText,
Embedding: queryEmbedding,
KnowledgeBaseIDs: []string{id},
TopK: matchCount,
Threshold: params.VectorThreshold,
RetrieverType: types.VectorRetrieverType,
KnowledgeIDs: params.KnowledgeIDs,
TagIDs: params.TagIDs,
}
// FAQ 检索构建
if kb.Type == types.KnowledgeBaseTypeFAQ {
vectorParams.KnowledgeType = types.KnowledgeTypeFAQ
}
retrieveParams = append(retrieveParams, vectorParams)
// 关键词检索构建
retrieveParams = append(retrieveParams, types.RetrieveParams{
Query: params.QueryText,
KnowledgeBaseIDs: []string{id},
TopK: matchCount,
Threshold: params.KeywordThreshold,
RetrieverType: types.KeywordsRetrieverType,
KnowledgeIDs: params.KnowledgeIDs,
TagIDs: params.TagIDs,
})
// 执行检索
retrieveResults, err := retrieveEngine.Retrieve(ctx, retrieveParams)
Tips:这里实际的检索数量为指定检索数量的 3 倍(matchCount := params.MatchCount * 3),因为后续需要对检索结果进行去重和融合的操作
合并结果
对向量结果进行去重 + 按分数排序
for _, info := range chunkInfoMap {
deduplicatedChunks = append(deduplicatedChunks, info)
}
slices.SortFunc(deduplicatedChunks, func(a, b *types.IndexWithScore) int {
if a.Score > b.Score {
return -1
} else if a.Score < b.Score {
return 1
}
return 0
})
使用 RRF 算法对向量结果和关键词结果进行融合排序。RRF 算法简单来说就是:
对于每个检索器打的具体分数(因为标准不同),RRF 只关心文档在每个结果列表里的名次,然后把名次换算成分数,把多个列表的分数加起来重新排名,选出大家都认为靠前的好结果。
const rrfK = 60
// 向量检索排名
vectorRanks[chunkID] = rank
// 关键词检索排名
keywordRanks[chunkID] = rank
// 计算RRF分数
rrfScore = 1.0/(60+vectorRank) + 1.0/(60+keywordRank)
迭代检索补充
若同时满足以下条件,则触发对检索结果的迭代检索补充,:
- 向量检索结果数量不足 params.MatchCount 个
- 检索类型为 FAQ,检索类型有 Document 和 FAQ。类型 Document 只对文本块进行检索,类型 FAQ 除了基础的文本块检索还支持对问答对进行检索。
- 向量检索结果数量达到最大检索数量
needsIterativeRetrieval := len(deduplicatedChunks) < params.MatchCount &&
kb.Type == types.KnowledgeBaseTypeFAQ && len(vectorResults) == matchCount
if needsIterativeRetrieval {
logger.Info(ctx, "Not enough unique chunks, using iterative retrieval for FAQ")
deduplicatedChunks = s.iterativeRetrieveWithDeduplication(
ctx,
retrieveEngine,
retrieveParams,
params.MatchCount,
params.QueryText,
)
}
迭代检索初始化检索数量依然是指定检索数量的 3 倍,currentTopK := matchCount * 3,默认进行 5 次迭代检索 maxIterations := 5,每次迭代检索数量为上一次的 2 倍 currentTopK *= 2。
当迭代检索满足以下条件时,会提前终止:
- 已获得足够 chunks
- 检索结果 < TopK 无更多结果
经过迭代检索补充后获取的 chunks list,会进行去重和排序操作,对其中 FAQ chunks 会进行负问题匹配过滤。负问题在写入 FAQ 知识库时会被存储在 FAQ 的 meta.NegativeQuestions 字段中。 例如:问题: "如何开通会员?" -> 负问题: ["如何取消会员", "如何退订会员"]。
// Returns true if the query matches any negative question, false otherwise.
func (s *knowledgeBaseService) matchesNegativeQuestions(queryTextLower string, negativeQuestions []string) bool {
if len(negativeQuestions) == 0 {
return false
}
for _, negativeQ := range negativeQuestions {
negativeQLower := strings.ToLower(strings.TrimSpace(negativeQ))
if negativeQLower == "" {
continue
}
// Check if query text is exactly the same as the negative question
if queryTextLower == negativeQLower {
return true
}
}
return false
}
Tips:对于 FAQ chunks 与 document chunks,在某些场景下,用户希望答案优先采用 FAQ chunks 中的内容,可以通过设置以下三个参数来提高 FAQ chunks 在检索结果中的权重。
FAQPriorityEnabled:是否开启 FAQ 优先级,默认值为false。FAQDirectAnswerThreshold:FAQ 直接回答阈值。FAQScoreBoost:FAQ 分数提升值,大于等于 1.0。
Web 检索
Web 检索支持 duckduckgo 搜索和 google 搜索两个方式,默认开启 duckduckgo 搜索。对经过重写的用户查询进行 Web 检索后,会对检索结果进行黑名单过滤。
DuckDuckGo 搜索
DuckDuckGo 搜索(简称 DDG),是一个基于隐私的搜索引擎,不存储用户搜索历史。DDG 搜索的结果是实时的,不会缓存搜索结果。常见于 搜索、RAG、Agent、隐私优先的 Web 查询场景。
DDG 支持两种搜索模式:
- HTML:获取网页版搜索结果页面,返回 HTML 格式信息。
- API:获取结构化的数据(如即时答案、摘要),返回 JSON 格式信息。
在 WeKnotra 中,DDG 优先使用 HTML 模式进行搜索,因为 HTML 模式返回的结果更丰富,包含了搜索结果的标题、摘要、链接等信息。降级使用 API 模式。
// Try HTML scraping first (more reliable for general results)
htmlResults, err := p.searchHTML(ctx, query, maxResults)
if err == nil && len(htmlResults) > 0 {
return htmlResults, nil
}
// Fallback to Instant Answer API
apiResults, apiErr := p.searchAPI(ctx, query, maxResults)
if apiErr == nil && len(apiResults) > 0 {
return apiResults, nil
}
Google 搜索
Google 搜索需要配置 Google API Key 和 Google Custom Search Engine ID。
黑名单过滤
对检索到的链接进行黑名单过滤,支持通配符模式(如 ://.example.com/*)和正则(如 /example.(net|org)/)两种方式。
func (s *WebSearchService) matchesBlacklistRule(url, rule string) bool {
// Check if it's a regex pattern (starts and ends with /)
if strings.HasPrefix(rule, "/") && strings.HasSuffix(rule, "/") {
pattern := rule[1 : len(rule)-1]
matched, err := regexp.MatchString(pattern, url)
if err != nil {
logger.Warnf(context.Background(), "Invalid regex pattern in blacklist: %s, error: %v", rule, err)
return false
}
return matched
}
// Pattern matching (e.g., *://*.example.com/*)
pattern := strings.ReplaceAll(rule, "*", ".*")
pattern = "^" + pattern + "$"
matched, err := regexp.MatchString(pattern, url)
if err != nil {
logger.Warnf(context.Background(), "Invalid pattern in blacklist: %s, error: %v", rule, err)
return false
}
return matched
}
压缩 Web 检索结果
先创建临时知识库,若该会话(session id)已存在临时知识库,则直接复用该知识库。
if createdKB == nil {
kb := &types.KnowledgeBase{
Name: fmt.Sprintf("tmp-websearch-%d", time.Now().UnixNano()),
Description: "Ephemeral search compression KB",
IsTemporary: true,
EmbeddingModelID: cfg.EmbeddingModelID,
}
createdKB, err = kbSvc.CreateKnowledgeBase(ctx, kb)
if err != nil {
return nil, tempKBID, seenURLs, knowledgeIDs, fmt.Errorf(
"failed to create temporary knowledge base: %w",
err,
)
}
tempKBID = createdKB.ID
}
将 Web 检索结果中的源链接、标题、摘要、正文内容信息合并为一个 Passage,写入到临时知识库中。
for _, r := range webSearchResults {
sourceURL := r.URL
title := strings.TrimSpace(r.Title)
snippet := strings.TrimSpace(r.Snippet)
body := strings.TrimSpace(r.Content)
// skip if already ingested for this KB
if sourceURL != "" && seenURLs[sourceURL] {
continue
}
contentLines := make([]string, 0, 4)
contentLines = append(contentLines, fmt.Sprintf("[sourceUrl]: %s", sourceURL))
if title != "" {
contentLines = append(contentLines, title)
}
if snippet != "" {
contentLines = append(contentLines, snippet)
}
if body != "" {
contentLines = append(contentLines, body)
}
knowledge, err := knowSvc.CreateKnowledgeFromPassageSync(ctx, createdKB.ID, contentLines)
if err != nil {
logger.Warnf(ctx, "failed to ingest passage into temp KB: %v", err)
continue
}
if sourceURL != "" {
seenURLs[sourceURL] = true
}
knowledgeIDs = append(knowledgeIDs, knowledge.ID)
}
Tips:注意,CreateKnowledgeFromPassageSync 这里不仅仅是将 contentLines 写入到知识库存储中,还会对 contentLines 进行向量化,生成 Embedding 向量。分别对 contentLines 中的元素进行安全性校验,和归一化处理,再直接按照每个元素作为一个 chunk 的方式进行向量化。
使用重写查询,在临时知识库中对 chunk 进行检索召回。
params := types.SearchParams{
QueryText: q,
VectorThreshold: 0.5,
KeywordThreshold: 0.5,
MatchCount: matchCount,
}
results, err := kbSvc.HybridSearch(ctx, tempKBID, params)
使用轮询分配算法,从检索结果中公平选择引用,确保每个来源网站都有内容能够被引用。
selected := s.selectReferencesRoundRobin(webSearchResults, allRefs, matchCount*len(webSearchResults))
将最终的 chunk list 按照各个来源分组后合并回原始的 WebSearchResult 结构。
compressedResults := s.consolidateReferencesByURL(webSearchResults, selected)
最后将本次压缩后的 WebSearchResult 进行缓存与会话(session id)进行关联,后续检索时用于校验去重等操作。当会话清除时,临时知识库以及缓存中的数据也会被清除。
问题扩写
触发条件
经过知识库检索和 Web 检索后,若检索到的结果数量不足预期数量的一半,且开启了问题扩写功能,则进行问题扩写。
if chatManage.EnableQueryExpansion && len(chatManage.SearchResult) < max(1, chatManage.EmbeddingTopK/2) {
expansions := p.expandQueries(ctx, chatManage)
...
}
扩写策略
- 对原始问题进行分词,提取有效关键词。
keywords := extractKeywords(query)
// 预定义中英文停用词
var stopwords = map[string]struct{}{
"的": {}, "是": {}, "在": {}, "了": {}, "和": {}, "与": {}, "或": {},
"a": {}, "an": {}, "the": {}, "is": {}, "are": {}, "was": {}, "were": {},
"be": {}, "been": {}, "being": {}, "have": {}, "has": {}, "had": {},
"do": {}, "does": {}, "did": {}, "will": {}, "would": {}, "could": {},
"should": {}, "may": {}, "might": {}, "must": {}, "can": {},
"to": {}, "of": {}, "in": {}, "for": {}, "on": {}, "with": {}, "at": {},
"by": {}, "from": {}, "as": {}, "into": {}, "through": {}, "about": {},
"what": {}, "how": {}, "why": {}, "when": {}, "where": {}, "which": {},
"who": {}, "whom": {}, "whose": {},
}
// 提取有效关键词
func extractKeywords(text string) []string {
words := tokenize(text) // 分词
keywords := make([]string, 0, len(words))
for _, w := range words {
lower := strings.ToLower(w)
// 过滤停用词和单字符
if _, isStop := stopwords[lower]; !isStop && len(w) > 1 {
keywords = append(keywords, w)
}
}
return keywords
}
- 按照规则提取有效短语
phrases := extractPhrases(query)
// 提取短语(双引号、单引号、中文引号等)
func extractPhrases(text string) []string {
var phrases []string
// 匹配各种引号内的内容
re := regexp.MustCompile(`["'"'「」『』]([^"'"'「」『』]+)["'"'「」『』]`)
matches := re.FindAllStringSubmatch(text, -1)
for _, m := range matches {
if len(m) > 1 && len(m[1]) > 2 { // 长度 > 2
phrases = append(phrases, m[1])
}
}
return phrases
}
- 分隔符拆分问题
segments := splitByDelimiters(query)
// 分隔符拆分问题(空格、逗号、句号等)
func splitByDelimiters(text string) []string {
// 按标点符号和空白分割
re := regexp.MustCompile(`[,,;;、。!?!?\s]+`)
parts := re.Split(text, -1)
var result []string
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
result = append(result, p)
}
}
return result
}
- 查询变体,移除疑问词
cleaned := removeQuestionWords(query)
// 疑问词
var questionWords = regexp.MustCompile(
`^(什么是|什么|如何|怎么|怎样|为什么|为何|哪个|哪些|谁|何时|何地|请问|请告诉我|帮我|我想知道|我想了解)`,
)
// 移除预定义疑问词(如:“什么是”、“如何”等)
func removeQuestionWords(text string) string {
return strings.TrimSpace(questionWords.ReplaceAllString(text, ""))
}
通过以上策略扩写问题,每个策略都会生成 1 个或多个变体。将这些变体合并去重后,只取前 expansions := make([]string, 0, 5) 个变体作为最终的扩写结果。
最后,将扩写后的问题进行检索召回。
res, err := p.knowledgeBaseService.HybridSearch(ctx, t.KnowledgeBaseID, paramsExp)
检索结果处理
对检索结果列表 SearchResult 进行上下文添加,去重等操作,得到最终的混合检索结果。
获取历史引用
返回最近一轮有知识引用的结果,引用标记为 MatchTypeHistory,加入检索结果列表 SearchResult。
// Add relevant results from chat history
historyResult := p.getSearchResultFromHistory(chatManage)
if historyResult != nil {
chatManage.SearchResult = append(chatManage.SearchResult, historyResult...)
}
检索结果去重
对检索结果列表 SearchResult 进行去重,移除重复的结果。这里去重分别对 chunk id 和 chunk content 进行双层去重。
// Remove duplicate results
chatManage.SearchResult = removeDuplicateResults(chatManage.SearchResult)
知识图谱检索
知识图谱检索相较于混合检索简单很多,和传统方案一样,根据问题中提取的实体进行实体搜索,返回相关 chunk。问题中的实体提取操作在问题重写事件中已经触发。知识图谱默认支持 Neo4j。
graph, err := p.graphRepo.SearchNode(ctx, types.NameSpace{KnowledgeBase: knowledgeBaseID,Knowledge: knowledgeID}, entity)
对召回的 chunk 进行相关信息提取,数据结构转换,去重等操作后,加入检索结果列表 SearchResult。
检索重排
Rerank 模型选择
WeKnora 支持 OpenAI(OpenAIReranker)、阿里云 DashScope(AliyunReranker)、智谱(ZhipuReranker),以及 Jina(JinaReranker)四种 rerank 方案,可以根据实际场景指定 RerankModelID 值来进行选择。
switch providerName {
case provider.ProviderAliyun:
return NewAliyunReranker(config)
case provider.ProviderZhipu:
return NewZhipuReranker(config)
case provider.ProviderJina:
return NewJinaReranker(config)
default:
return NewOpenAIReranker(config)
}
rerank 无默认值,若未设置 RerankModelID 则会跳过 rerank 过程。
if chatManage.RerankModelID == "" {
return next()
}
Passages 构建
先对检索结果列表 SearchResult 进行结果分类,在混合检索中通过小文件加载策略标记为 types.MatchTypeDirectLoad 的 directLoadResults 跳过 rerank,直接进入最终结果计算。
if result.MatchType == types.MatchTypeDirectLoad {
directLoadResults = append(directLoadResults, result)
pipelineInfo(ctx, "Rerank", "direct_load_skip", map[string]interface{}{
"chunk_id": result.ID,
})
continue
}
提取 result 中 chunk 相关的图片描述(Caption),图片 OCR 文本,相关问题(GeneratedQuestions)等信息加入 passages 构建,进行信息增强。
// 合并Content和ImageInfo的文本内容
passage := getEnrichedPassage(ctx, result)
if result.ImageInfo != "" {
var imageInfos []types.ImageInfo
err := json.Unmarshal([]byte(result.ImageInfo), &imageInfos)
if err != nil {
pipelineWarn(ctx, "Rerank", "image_info_parse", map[string]interface{}{
"error": err.Error(),
})
} else {
// 提取所有图片的描述和OCR文本
for _, img := range imageInfos {
if img.Caption != "" {
enrichments = append(enrichments, fmt.Sprintf("图片描述: %s", img.Caption))
}
if img.OCRText != "" {
enrichments = append(enrichments, fmt.Sprintf("图片文本: %s", img.OCRText))
}
}
}
}
// 解析ChunkMetadata中的GeneratedQuestions
if len(result.ChunkMetadata) > 0 {
var docMeta types.DocumentChunkMetadata
err := json.Unmarshal(result.ChunkMetadata, &docMeta)
if err != nil {
pipelineWarn(ctx, "Rerank", "chunk_metadata_parse", map[string]interface{}{
"error": err.Error(),
})
} else if questionStrings := docMeta.GetQuestionStrings(); len(questionStrings) > 0 {
enrichments = append(enrichments, fmt.Sprintf("相关问题: %s", strings.Join(questionStrings, "; ")))
}
}
if len(enrichments) == 0 {
return combinedText
}
// 组合内容和增强信息
if combinedText != "" {
combinedText += "\n\n"
}
combinedText += strings.Join(enrichments, "\n")
Rerank
将 passages 列表和问题传入 rerank 模型,获取 rerank 后的相关性分数。并使用默认 RerankThreshold 值(0.5)对 rerank 结果进行过滤。
rerankResp, err := rerankModel.Rerank(ctx, query, passages)
...
// Filter results based on threshold with special handling for history matches
rankFilter := []rerank.RankResult{}
for _, result := range rerankResp {
if result.Index >= len(candidates) {
continue
}
th := chatManage.RerankThreshold
matchType := candidates[result.Index].MatchType
if matchType == types.MatchTypeHistory {
th = math.Max(th-0.1, 0.5) // Lower threshold for history matches
}
if result.RelevanceScore > th {
rankFilter = append(rankFilter, result)
}
}
return rankFilter
Tips:若引用片段标记为历史引用 MatchTypeHistory,将阈值匹配降低 0.1,最低限制不低于 0.5。因为“历史引用”这类结果通常是上轮已用过 / 用户刚提过,可能对当前问题仍然有价值,但 rerank 分数未必很高,所以稍微放宽过滤,提高留存概率。
阈值降级策略
若 rerank 结果中无符合阈值的引用片段,且当前匹配阈值 > 0.3,则将阈值降级重新 rerank,新阈值 = 原阈值 × 0.7,最低不低于 0.3。
if len(rerankResp) == 0 && originalThreshold > 0.3 {
degradedThreshold := originalThreshold * 0.7
if degradedThreshold < 0.3 {
degradedThreshold = 0.3
}
chatManage.RerankThreshold = degradedThreshold
rerankResp = p.rerank(ctx, chatManage, rerankModel, chatManage.RewriteQuery, passages, candidatesToRerank)
// Restore original threshold
chatManage.RerankThreshold = originalThreshold
}
计算最终评分
所有引用片段的计算最终评分。
- 包含 Passages 构建中跳过 rerank 的 directLoadResults 结果,假设高相关,即 modelScore 直接设为 1.0
- FAQ 类引用片段,如果有设置 FAQScoreBoost 来提高 FAQ 类引用片段的分数,会额外乘以 FAQScoreBoost 因子
最终评分的计算公式为:
composite = (0.6 × modelScore + 0.3 × baseScore + 0.1 × sourceWeight) × positionPrior
权重设计:
- 60%:rerank 模型分数(相关性)
- 30%:原始检索分数(baseScore)
- 10%:来源权重(web_search=0.95,其他=1.0)
composite := 0.6*modelScore + 0.3*baseScore + 0.1*sourceWeight
positionPrior:位置先验因子,根据引用片段在原文中的位置(StartAt),位置越靠前越有优势,会有最多 +5% 的轻微加成;反之最多 -5%。因为当分数接近时,倾向选择更“前置/摘要/定义”类片段(很多文档重要信息在开头)。
positionPrior := 1.0
if sr.StartAt >= 0 {
positionPrior += searchutil.ClampFloat(1.0-float64(sr.StartAt)/float64(sr.EndAt+1), -0.05, 0.05)
}
composite *= positionPrior
MMR 多样性选择
在保证高相关性的前提下,强制筛选出信息更全面、内容更不重复的文档集合,防止 LLM 收到一堆内容雷同的“废话”,最终生成更全面、更有洞见的答案。
lambda := 0.7
mmr := lambda*relevance - (1.0-lambda)*redundancy
- Lambda 是调节相关性和冗余度的权重参数,取值范围为 [0,1]。值越大表示更重视相关性,值越小表示更重视多样性。这里设置为 0.7,表示更重视相关性。
- Relevance 相关性即为上一步计算得出的 composite 分数。
- Redundancy 冗余度则是指文档集合中不同文档之间的相似度,使用 Jaccard 相似度计算。计算候选文档与每一个已选文档的相似度,然后取最大值。
sim := searchutil.Jaccard(allTokenSets[i], selTokens)
if sim > redundancy {
redundancy = sim
}
合并结果
对最终 Results 列表进行合并,优先使用 RerankResult,为空则降级到 SearchResult。
Chunks 列表构建
按 KnowledgeID 分组,再按 ChunkType 细分,最终输出结构:map[KnowledgeID]map[ChunkType][]SearchResult
knowledgeGroup := make(map[string]map[string][]*types.SearchResult)
for _, chunk := range searchResult {
if _, ok := knowledgeGroup[chunk.KnowledgeID]; !ok {
knowledgeGroup[chunk.KnowledgeID] = make(map[string][]*types.SearchResult)
}
knowledgeGroup[chunk.KnowledgeID][chunk.ChunkType] = append(knowledgeGroup[chunk.KnowledgeID][chunk.ChunkType], chunk)
}
按照 StartAt 位置进行升序排序,若 StartAt 相同,则按 EndAt 位置升序排序。
sort.Slice(chunks, func(i, j int) bool {
if chunks[i].StartAt == chunks[j].StartAt {
return chunks[i].EndAt < chunks[j].EndAt
}
return chunks[i].StartAt < chunks[j].StartAt
})
合并重叠/相邻内容
根据位置信息合并重叠/相邻 Chunks。合并后取最高的 Score。
knowledgeMergedChunks := []*types.SearchResult{chunks[0]}
for i := 1; i < len(chunks); i++ {
lastChunk := knowledgeMergedChunks[len(knowledgeMergedChunks)-1]
// If the current chunk starts after the last chunk ends, add it to the merged chunks
if chunks[i].StartAt > lastChunk.EndAt {
knowledgeMergedChunks = append(knowledgeMergedChunks, chunks[i])
continue
}
// Merge overlapping chunks
if chunks[i].EndAt > lastChunk.EndAt {
lastChunk.Content = lastChunk.Content +
string([]rune(chunks[i].Content)[lastChunk.EndAt-chunks[i].StartAt:])
lastChunk.EndAt = chunks[i].EndAt
lastChunk.SubChunkID = append(lastChunk.SubChunkID, chunks[i].ID)
...
}
if chunks[i].Score > lastChunk.Score {
lastChunk.Score = chunks[i].Score
}
}
以 URL 作为唯一标识,对 ImageInfo 进行合并。
// 合并 ImageInfo
if err := mergeImageInfo(ctx, lastChunk, chunks[i]); err != nil {
pipelineWarn(ctx, "Merge", "image_merge", map[string]interface{}{
"knowledge_id": knowledgeID,
"error": err.Error(),
})
}
FAQ 内容填充
对召回的 FAQ 类引用片段,填充完整的 FAQ 内容。最终填充格式为:
Q: 标准问题
Answer:
- 答案1
- 答案2
短内容扩展
对普通的引用片段,若内容长度 < 350 字符的短内容,根据当前位置信息进行前后迭代扩展,迭代扩展先向前,再向后,直到达到 maxLen = 850 或无法继续。并更新扩展后的 metadata 信息。
merged = mergeOrderedContent(prevContent, baseChunk.Content, nextContent, maxLen)
结果过滤
对 Results 列表进行过滤,保留前 topK 文本块。
filterTopK := func(searchResult []*types.SearchResult, topK int) []*types.SearchResult {
if topK > 0 && len(searchResult) > topK {
pipelineInfo(ctx, "FilterTopK", "filter", map[string]interface{}{
"before": len(searchResult),
"after": topK,
})
searchResult = searchResult[:topK]
}
return searchResult
}
对结果集进行降级策略,优先合并后结果 MergeResult,重排序结果次之 RerankResult,最后是原始的检索结果 SearchResult。
if len(chatManage.MergeResult) > 0 {
chatManage.MergeResult = filterTopK(chatManage.MergeResult, chatManage.RerankTopK)
} else if len(chatManage.RerankResult) > 0 {
chatManage.RerankResult = filterTopK(chatManage.RerankResult, chatManage.RerankTopK)
} else if len(chatManage.SearchResult) > 0 {
chatManage.SearchResult = filterTopK(chatManage.SearchResult, chatManage.RerankTopK)
} else {
pipelineWarn(ctx, "FilterTopK", "skip", map[string]interface{}{
"reason": "no_results",
})
}
数据分析
如果最终的 Results 列表中关联的文件包含数据文件(如 .csv、.xlsx、.xls 等),则需要对数据进行处理,并通过 LLM 根据用户问题生成查询语句,对数据进行查询输出最终结果添加到 Results 列表中。
文档识别
识别 Results 列表中关联的文件是否包含数据文件(如 .csv、.xlsx、.xls 等)。
for _, result := range chatManage.MergeResult {
if isDataFile(result.KnowledgeFilename) {
dataFiles = append(dataFiles, result)
}
}
若存在数据文件,则移除检索结果 Results 列表中的数据文件相关表结构引用(如:ChunkTypeTableColumn 和 ChunkTypeTableSummary),避免重复引用。
func filterOutTableChunks(results []*types.SearchResult) []*types.SearchResult {
filtered := make([]*types.SearchResult, 0, len(results))
filterList := []string{string(types.ChunkTypeTableColumn), string(types.ChunkTypeTableSummary)}
for _, result := range results {
if slices.Contains(filterList, result.ChunkType) {
continue
}
filtered = append(filtered, result)
}
return filtered
}
加载数据
将数据加载到 DuckDB,根据 knowledgeID 生成表名 k_{knowledgeID}。
- .csv 文件使用
read_csv_auto进行创建。
func (t *DataAnalysisTool) LoadFromCSV(ctx context.Context, filename string, tableName string) (*TableSchema, error) {
logger.Infof(ctx, "[Tool][DataAnalysis] Loading CSV file '%s' into table '%s' for session %s", filename, tableName, t.sessionID)
// Record the created table for cleanup. If already exists, skip creation
if t.recordCreatedTable(tableName) {
// Create table from CSV using DuckDB's read_csv_auto function
// Table will be created in the session schema
createTableSQL := fmt.Sprintf("CREATE TABLE \"%s\" AS SELECT * FROM read_csv_auto('%s')", tableName, filename)
_, err := t.db.ExecContext(ctx, createTableSQL)
if err != nil {
logger.Errorf(ctx, "[Tool][DataAnalysis] Failed to create table from CSV: %v", err)
return nil, fmt.Errorf("failed to create table from CSV: %w", err)
}
logger.Infof(ctx, "[Tool][DataAnalysis] Successfully created table '%s' from CSV file in session %s", tableName, t.sessionID)
}
// Get and return the table schema
return t.LoadFromTable(ctx, tableName)
}
- .xlsx、.xls 文件使用
st_read进行创建。
func (t *DataAnalysisTool) LoadFromExcel(ctx context.Context, filename string, tableName string) (*TableSchema, error) {
logger.Infof(ctx, "[Tool][DataAnalysis] Loading Excel file '%s' into table '%s' for session %s", filename, tableName, t.sessionID)
// Record the created table for cleanup. If already exists, skip creation
if t.recordCreatedTable(tableName) {
// Try to read Excel file using st_read (from spatial extension)
// If spatial extension doesn't support Excel, we'll need to convert to CSV first
createTableSQL := fmt.Sprintf("CREATE TABLE \"%s\" AS SELECT * FROM st_read('%s')", tableName, filename)
_, err := t.db.ExecContext(ctx, createTableSQL)
if err != nil {
logger.Errorf(ctx, "[Tool][DataAnalysis] Failed to create table from Excel: %v", err)
return nil, fmt.Errorf("failed to create table from Excel file. Consider converting to CSV first: %w", err)
}
logger.Infof(ctx, "[Tool][DataAnalysis] Successfully created table '%s' from Excel file in session %s", tableName, t.sessionID)
}
// Get and return the table schema
return t.LoadFromTable(ctx, tableName)
}
生成 SQL
将用户问题,Knowledge ID,对应表结构(列名、类型、行数)加入 prompt,让 LLM 判断是否需要生成 SQL 查询语句。
analysisPrompt := fmt.Sprintf(`
User Question: %s
Knowledge ID: %s
Table Schema: %s
Determine if the user's question requires data analysis (e.g., statistics, aggregation, filtering) on this table.
If YES, generate a DuckDB SQL query to answer the user's question and fill in the knowledge_id and sql fields.
If NO, leave the sql field empty.
Return your response in the specified JSON format.`, chatManage.Query, knowledge.ID, schema.Description())
response, err := chatModel.Chat(ctx, []chat.Message{
{Role: "user", Content: analysisPrompt},
}, &chat.ChatOptions{
Temperature: 0.1,
Format: formatSchema,
})
如果生成了 SQL 语句,需要对 SQL 语句进行校验,仅允许只读查询:SELECT、SHOW、DESCRIBE、EXPLAIN、PRAGMA 安全操作,执行 SQL 语句。
toolResult, err := tool.Execute(ctx, json.RawMessage(response.Content))
if err != nil {
logger.Errorf(ctx, "Failed to execute SQL: %v", err)
return next()
}
最后将数据分析结果加入 Results 列表中。
构建最终消息体
查询安全性校验
对用户的输入查询进行安全性校验。个人认为安全性校验步骤应该在 Pipeline 最开始的步骤中(例如:rewrite)中进行是否更为合适,从源头对危险查询进行拦截,也减少资源浪费。
safeQuery, isValid := utils.ValidateInput(chatManage.Query)
主要是针对控制字符,UTF-8 有效性,XSS 攻击三方面安全性进行校验。
func ValidateInput(input string) (string, bool) {
if input == "" {
return "", true
}
// 检查是否包含控制字符
for _, r := range input {
if r < 32 && r != 9 && r != 10 && r != 13 {
return "", false
}
}
// 检查 UTF-8 有效性
if !utf8.ValidString(input) {
return "", false
}
// 检查是否包含潜在的 XSS 攻击
for _, pattern := range xssPatterns {
if pattern.MatchString(input) {
return "", false
}
}
return strings.TrimSpace(input), true
}
引用信息上下文构建
FAQ 分离(可选)
若开启了 FAQ 优先配置(FAQPriorityEnabled = true),则会将 results 分为 FAQ 和文档两类,并对 FAQ 进行高置信度检测 Score >= FAQDirectAnswerThreshold,且确保只返回一个高置信度的 FAQ。
if chatManage.FAQPriorityEnabled {
for _, result := range chatManage.MergeResult {
if result.ChunkType == string(types.ChunkTypeFAQ) {
faqResults = append(faqResults, result)
// Check if this FAQ has high confidence (above direct answer threshold)
if result.Score >= chatManage.FAQDirectAnswerThreshold && !hasHighConfidenceFAQ {
hasHighConfidenceFAQ = true
}
} else {
docResults = append(docResults, result)
}
}
}
构建引用上下文
上下文构建分为两种情况:
- 开启 FAQ 优先配置,有高置信度的 FAQ 时,将高置信度的 FAQ 加入上下文。
if chatManage.FAQPriorityEnabled && len(faqResults) > 0 {
// Build structured context with FAQ prioritization
contextsBuilder.WriteString("### 资料来源 1:标准问答库 (FAQ)\n")
contextsBuilder.WriteString("【高置信度 - 请优先参考】\n")
for i, result := range faqResults {
passage := getEnrichedPassageForChat(ctx, result)
if hasHighConfidenceFAQ && i == 0 {
contextsBuilder.WriteString(fmt.Sprintf("[FAQ-%d] ⭐ 精准匹配: %s\n", i+1, passage))
} else {
contextsBuilder.WriteString(fmt.Sprintf("[FAQ-%d] %s\n", i+1, passage))
}
}
if len(docResults) > 0 {
contextsBuilder.WriteString("\n### 资料来源 2:参考文档\n")
contextsBuilder.WriteString("【补充资料 - 仅在FAQ无法解答时参考】\n")
for i, result := range docResults {
passage := getEnrichedPassageForChat(ctx, result)
contextsBuilder.WriteString(fmt.Sprintf("[DOC-%d] %s\n", i+1, passage))
}
}
}
// 输出示例
### 资料来源 1:标准问答库 (FAQ)
【高置信度 - 请优先参考】
[FAQ-1] ⭐ 精准匹配: [FAQ内容]
[FAQ-2] [FAQ内容]
### 资料来源 2:参考文档
【补充资料 - 仅在FAQ无法解答时参考】
[DOC-1] [文档内容]
[DOC-2] [文档内容]
- 未开启 FAQ 优先配置或无高置信度的 FAQ 时,直接将所有文档加入上下文。
passages := make([]string, len(chatManage.MergeResult))
for i, result := range chatManage.MergeResult {
passages[i] = getEnrichedPassageForChat(ctx, result)
}
for i, passage := range passages {
if i > 0 {
contextsBuilder.WriteString("\n\n")
}
contextsBuilder.WriteString(fmt.Sprintf("[%d] %s", i+1, passage))
}
// 输出示例
[1] [内容1]
[2] [内容2]
[3] [内容3]
图片信息处理
若引用中包含图片信息 ImageInfo,会对图片信息进行解析后,根据 URL 的位置进行相应的内容(图片 OCR 和 图片描述 Caption)的插入。
// 查找内容中的所有Markdown图片链接
matches := markdownImageRegex.FindAllStringSubmatch(content, -1)
// 替换每个图片链接,添加描述和OCR文本
for _, match := range matches {
if len(match) < 3 {
continue
}
// 提取图片URL,忽略alt文本
imgURL := match[2]
// 标记该URL已处理
processedURLs[imgURL] = true
// 查找匹配的图片信息
imgInfo, found := imageInfoMap[imgURL]
// 如果找到匹配的图片信息,添加描述和OCR文本
if found && imgInfo != nil {
replacement := match[0] + "\n"
if imgInfo.Caption != "" {
replacement += fmt.Sprintf("图片描述: %s\n", imgInfo.Caption)
}
if imgInfo.OCRText != "" {
replacement += fmt.Sprintf("图片文本: %s\n", imgInfo.OCRText)
}
content = strings.Replace(content, match[0], replacement, 1)
}
}
// 输出示例
原始内容: 
增强后:

图片描述: 这是一张销售趋势图表
图片文本: 2024年Q1销售额: 100万
构建最终消息体
替换消息体中的对应变量后,输出最终的消息体 userContent。
- {{query}}:用户查询(已安全验证)
- {{contexts}}:构建的上下文内容
- {{current_time}}:当前时间(格式:2006-01-02 15:04:05)
- {{current_week}}:当前星期(中文:星期一、星期二等)
// Replace placeholders in context template
userContent := chatManage.SummaryConfig.ContextTemplate
userContent = strings.ReplaceAll(userContent, "{{query}}", safeQuery)
userContent = strings.ReplaceAll(userContent, "{{contexts}}", contextsBuilder.String())
userContent = strings.ReplaceAll(userContent, "{{current_time}}", time.Now().Format("2006-01-02 15:04:05"))
userContent = strings.ReplaceAll(userContent, "{{current_week}}", weekdayName[time.Now().Weekday()])
// Set formatted content back to chat management
chatManage.UserContent = userContent
流式聊天
构建消息列表
消息列表中包含三个信息:
- System Message:系统提示词
prompt: |
你是一个专业的智能信息检索助手,名为WeKnora。你犹如专业的高级秘书,依据检索到的信息回答用户问题,不能利用任何先验知识。
当用户提出问题时,助手会基于特定的信息进行解答。助手首先在心中思考推理过程,然后向用户提供答案。
## 回答问题规则
- 仅根据检索到的信息中的事实进行回复,不得运用任何先验知识,保持回应的客观性和准确性。
- 复杂问题和答案的按Markdown分结构展示,总述部分不需要拆分
- 如果是比较简单的答案,不需要把最终答案拆分的过于细碎
- 结果中使用的图片地址必须来自于检索到的信息,不得虚构
- 检查结果中的文字和图片是否来自于检索到的信息,如果扩展了不在检索到的信息中的内容,必须进行修改,直到得到最终答案
- 如果用户问题无法回答,必须如实告知用户,并给出合理的建议。
## 输出限制
- 以Markdown图文格式输出你的最终结果
- 输出内容要保证简短且全面,条理清晰,信息明确,不重复。
- History Messages:历史对话,User + Assistant 交替。在 rag_stream 的 pipline 中 rewrite 阶段已经做了历史记录提取,不仅用于问题改写,还用于这个极端的消息列表构建。
chatManage.History = historyList
- Current User Message:当前用户查询,即为上一个步骤中构建的 userContent。
调用 LLM 进行流式回复
调用 LLM 进行流式回复,将回复内容逐步返回给用户。
流式过滤
这里会对输出的内容进行前缀匹配过滤,若用户没有设置自定义的前缀匹配规则,则使用默认的前缀匹配规则。
<think>...</think>:思考过程标签(会被移除)NO_MATCH:无匹配标记 若最终没有获取到有效内容,会触发降级策略,降级策略分为两种模式 fix 和 model,默认为 model。- fix:输出固定回答
fallback_response: "抱歉,我无法回答这个问题。"
- model:调用 LLM 进行回复,使用预定义的 fallback_prompt。
fallback_prompt: |
你是一个专业、友好的AI助手。请根据你的知识直接回答用户的问题。
## 回复要求
- 直接回答用户的问题
- 简洁清晰,言之有物
- 如果涉及实时数据或个人隐私信息,诚实说明无法获取
- 使用礼貌、专业的语气
## 用户的问题是:
{{query}}
尾言
至此,围绕 WeKnora 的这一组源码解析也告一段落了。
在这个系列中,我们先从文档解析与切分入手,分析了 WeKnora 如何在复杂文档场景下构建结构稳定、语义完整的 Chunk;随后又聚焦 RAG 的检索与重排阶段,拆解了多路召回、降级策略、去重与筛选等一系列更贴近真实生产环境的工程实现。
如果一定要总结 WeKnora 的特点,它并不追求某一个环节“做到极致”,而是始终围绕一个目标展开:
在不稳定输入、不完美召回和有限上下文的前提下,尽可能稳定地为 LLM 提供可用信息。
很多实现细节——例如对 FAQ 的优先处理、rerank 失败时的阈值回退、Chunk 合并与位置先验——本身并不复杂,但它们几乎都来自真实系统中的失败经验,而不是论文中的理想假设。这也正是 WeKnora 这套方案最有参考价值的地方。
RAG 从来不是“接个向量库就结束”的问题,而是一套需要不断权衡、取舍和修正的工程系统。希望这个系列对你理解生产级 RAG 的真实形态,能提供一些具体而可落地的参考。