一开始做知识库问答时,我以为 RAG 的核心就是:

上传文档 -> 用户提问 -> 调用大模型 -> 返回答案

但真正把 RAG 接进项目后,我发现影响回答质量的关键不只是大模型,而是前面的检索与排序

如果检索没有找到正确资料,后面的大模型再强也只能乱猜。

如果检索找到了很多资料,但排序不好,真正有用的 chunk 可能排不到前面。

如果向量检索返回了“看起来相关”的内容,但其实没有答案,模型还可能一本正经地胡说。

所以这篇文章主要复盘我在项目中围绕“检索与排序”遇到的问题、解决方案和后续优化方向。


1. 项目背景:为什么需要检索?

我的项目是一个聊天系统,后来接入了一个独立的 Python FastAPI AI 服务 RAG Agent。

具体链路是:

用户在群知识库上传文档
-> Java 后端校验群权限
-> Java 将文件转发给 Python AI 服务
-> Python 保存文件、计算 MD5,并写入文档元数据
-> 用户触发解析
-> Python 按文件类型读取文本并切分 chunks
-> chunks 写入 SQLite
-> 用户触发索引
-> Python 将 chunks 向量化并写入 Chroma
-> 用户在群聊中提问
-> 系统只检索全局知识库 + 当前群知识库
-> 关键词检索 + 向量检索召回相关 chunk
-> 混合排序和证据判断
-> 生成回答并返回来源

这里最关键的一步就是:

用户问题 -> 检索知识库 -> 找到真正有用的 chunk

如果这一步做不好,就会出现两个典型问题:

1. 知识库里明明有资料,但系统没搜到。
2. 系统搜到了一些相关内容,但这些内容其实不能支撑回答。

这两个问题都和检索与排序有关。


2. 第一个问题:知识库里有 Netty 文档,但问 Netty 没命中

项目里我遇到过一个很典型的问题:

知识库中 netty 是什么?

知识库里明明已经上传了 Netty 相关文档,但系统一开始没有稳定命中。

这个问题让我意识到:只靠一种检索方式是不够的。

2.1 关键词检索的优点

关键词检索很好理解,就是看用户问题里的词有没有出现在 chunk 里。

比如问题是:

知识库中 netty 是什么?

如果某个 chunk 里出现了:

Netty 是一个异步事件驱动的网络应用框架

那么这个 chunk 就应该被搜出来。

关键词检索特别适合这些内容:

Netty
WebSocket
API
接口路径
类名
文件名
字段名

这些词不是靠“语义理解”就一定能稳定命中的,它们本身就是精确术语。

2.2 关键词检索的问题

但关键词检索也有缺点。

如果用户换一种说法,而文档里没有相同词,就可能搜不到。

比如文档里写的是:

RAG 评测指标包括 source_hit_rate 和 keyword_hit_rate

用户问:

怎么判断知识库问答效果好不好?

这时用户没有直接说 source_hit_rate,关键词检索可能就不稳定。

所以关键词检索适合精确词,但不适合所有语义问题。


3. 第二个问题:向量检索能召回语义,但不一定有答案

后来我接入了 Chroma 向量检索。

向量检索的思路是:

问题 -> Embedding 向量
chunk -> Embedding 向量
比较两个向量的语义距离
返回最相似的 chunks

它的优点是能处理“换一种说法”的问题。

比如用户问:

怎么证明 RAG 效果变好了?

文档里可能写的是:

通过 case_pass_rate、source_hit_rate、keyword_hit_rate 评估效果

虽然用户问题和文档原文不完全一样,但向量检索有机会命中。

但是,向量检索也带来了一个新问题:

最相似,不等于有答案。

比如用户问:

生产环境 IP 是什么?

知识库里其实没有生产环境 IP。

但向量检索可能会返回一些“部署”“服务”“环境”相关的 chunk。它们和问题主题相似,但没有真正回答 IP 是多少。

如果这时候直接把这些 chunk 交给大模型,模型可能会开始编造。

所以我后来加了证据判断:

检索结果相关
不代表
证据足够

对于“生产环境 IP”“公司内部薪资”“云服务器地址”这类精确事实问题,必须真的命中关键字段,否则宁可拒答。


4. 解决方案:关键词 + 向量混合检索

最后我把项目里的检索改成了混合检索。

核心思路是:

关键词检索
+ 向量检索
-> 合并
-> 去重
-> 加权排序
-> 得到最终 chunk

项目里的主流程可以简化成这样:

def run_hybrid_rag_search(question, top_k):
    keyword_items = keyword_search(question, top_k)
    vector_items = vector_search(question, top_k)

    if vector_items_has_enough_evidence(vector_items):
        final_items = merge_keyword_and_vector_items(
            keyword_items,
            vector_items,
            top_k
        )
        return final_items

    if keyword_items_has_enough_evidence(keyword_items):
        return keyword_items

    return []

这套逻辑背后的判断是:

1. 向量检索负责语义召回。
2. 关键词检索负责精确术语召回。
3. 两路都命中的 chunk 更可信。
4. 如果证据不足,不进入回答阶段。

5. 关键词是怎么提取的?

项目中有一个细节很重要:用户可能不会用空格分词。

比如:

知识库中netty是什么?

如果只按空格切分,这句话会被当成一个整体:

知识库中netty是什么?

这样很难命中文档中的 Netty

所以我做了一个简单的英文技术词提取:

def extract_ascii_search_terms(query):
    return re.findall(r"[a-zA-Z][a-zA-Z0-9_.+-]*", query)

这样可以从中文句子里提取出:

netty

然后关键词检索时会同时使用:

完整问题
分词结果
英文技术词

这样对中英文混合技术问题更友好。


6. 关键词怎么打分?

学习版关键词打分比较简单:

def calculate_keyword_score(query, terms, content):
    score = 0

    if query in content:
        score += 2

    for term in terms:
        if term in content:
            score += 1

    return score

也就是说:

完整问题命中,加 2 分
单个关键词命中,加 1 分
分数越高,排序越靠前

这个版本不复杂,但对初学阶段很有帮助,因为它足够直观,也容易调试。

比如问:

知识库中 netty 是什么?

如果 chunk 里包含 netty,它就会被排到前面。


7. 向量分数到底是什么?

向量检索这里很容易误解。

Chroma 原始返回的不是 score,而是 distance

distance 越小,表示问题和 chunk 越相似。

但在项目里,为了方便后续和关键词检索一起排序,我把 distance 转成了一个“越大越相关”的整数分数:

score = int(10000 / (1 + max(distance, 0.0)))

也就是说:

distance 越小
-> score 越大
-> 相关性越高

举几个例子:

distance = 0    -> score = 10000
distance = 0.3  -> score ≈ 7692
distance = 0.9  -> score ≈ 5263
distance = 1.0  -> score = 5000

这里的 score 不是准确率,也不是百分制,它只是项目内部为了排序设计的相关性分数。

后面做混合排序时,还会再把这个分数压缩一下:

def normalize_vector_score(raw_vector_score):
    return max(1, min(raw_vector_score // 10, 1000))

比如:

raw score = 7692 -> normalized score = 769
raw score = 5263 -> normalized score = 526
raw score = 10000 -> normalized score = 1000

这样做是为了避免向量分数太大,直接压过关键词分数。


8. 向量结果怎么和关键词结果合并?

混合检索里一个关键问题是:

如果同一个 chunk 同时被关键词和向量命中,应该怎么办?

我的做法是按 chunk_id 去重。

原因是同一个 chunk 不应该重复塞进 prompt。重复内容会浪费上下文,也可能干扰模型。

合并逻辑可以简化成:

merged = {}

for item in vector_items:
    merged[item.chunk_id] = normalize_vector_score(item.score)

for item in keyword_items:
    keyword_score = item.score * HYBRID_KEYWORD_SCORE_WEIGHT

    if item.chunk_id in merged:
        merged[item.chunk_id] += keyword_score
    else:
        merged[item.chunk_id] = keyword_score

return sort_by_score(merged)

这样做的好处是:

1. 同一个 chunk 不会重复出现。
2. 同时被关键词和向量命中的 chunk 会加分。
3. 最终排序更稳定。

这也符合直觉:

如果一个 chunk 既语义相似,又包含关键术语,
它大概率比只命中一路的 chunk 更可靠。

例如:

关键词分数 = 2
关键词权重 = 1200
向量原始分数 = 9000
向量归一化分数 = 900

最终合并后:

关键词部分:2 * 1200 = 2400
向量部分:900
最终分数:3300

这样可以让精确关键词命中的内容有更高权重,同时保留向量检索带来的语义召回能力。


9. 第三个问题:检索结果有了,但答案还是不好

做完混合检索后,我又遇到一个问题:

检索结果有了,但最终回答仍然不稳定。

原因可能有很多:

1. top_k 太小,真正有用的 chunk 没进来。
2. top_k 太大,引入太多噪声。
3. chunk 切分太碎,答案被切断。
4. 关键词结果和向量结果分数权重不合理。
5. 向量结果主题相关,但证据不足。
6. 证据门控过严,把本来能回答的问题拒掉了。

所以我后来做了一个 RAG 调试面板。

调试面板会展示:

问题类型
关键词
证据词
关键词检索结果
向量检索结果
最终片段
是否进入回答阶段
拒答原因

这一步非常重要。

因为 RAG 系统不能只看最终答案,要能解释:

为什么命中了这些 chunk?
为什么这个 chunk 排第一?
为什么最后拒答?
为什么没有进入大模型回答?

这才是工程化 RAG 和普通 demo 的区别。


10. 检索效果不好时,我怎么排查?

现在如果遇到“知识库里明明有内容,但 AI 没回答”,我会按下面顺序排查:

1. 问题是否真的在知识库范围内?
2. 文档是否已经上传成功?
3. 文档是否已经解析成 chunk?
4. chunk 是否已经写入 SQLite?
5. chunk 是否已经构建向量索引?
6. 当前群聊 scope 是否正确?
7. 关键词是否提取到了核心术语?
8. 向量检索 top_k 是否太小?
9. 混合排序后,相关 chunk 是否被压低?
10. 证据门控是否过严?

这里的 scope 也很重要。

在群聊场景中,A 群不能查到 B 群的知识库,所以检索范围必须限制在:

全局知识库 + 当前群知识库

如果 scope 错了,也会出现“明明上传了文档但搜不到”的问题。


11. 这次优化带来的收获

这次做完混合检索后,我对 RAG 的理解发生了变化。

以前我更关注:

模型能不能回答?

现在我更关注:

模型回答前拿到了什么资料?
这些资料是不是正确来源?
这些资料是否真的足够支撑回答?
最终排序为什么是这样?

这几个问题比“模型输出看起来像不像”更重要。

因为 RAG 的可靠性不是只来自模型,而是来自完整链路:

文档解析
-> chunk 切分
-> 关键词检索
-> 向量检索
-> 混合排序
-> 证据判断
-> Prompt 构造
-> 来源引用

其中任何一环出问题,最后回答都可能不稳定。


12. 后续优化方向

目前这套混合检索还是学习版,后续可以继续从下面几个方向优化。

12.1 引入 rerank

现在项目里的排序主要靠关键词分数和向量分数加权。这个方式简单、可解释,但它本质上还是规则排序。

后续可以加入 rerank。

rerank 可以理解为“二次排序”。

第一阶段先用关键词检索和向量检索召回一批候选 chunk,比如取前 20 个。

第二阶段再用一个更精细的模型或规则,对这 20 个 chunk 重新判断:

哪个 chunk 最能回答用户问题?
哪个 chunk 只是主题相关,但没有直接答案?
哪个 chunk 应该排在最前面?

简单来说:

第一阶段:召回
目标是尽量别漏掉可能相关的 chunk。

第二阶段:重排
目标是从候选 chunk 里挑出最有用、最能支撑回答的 chunk。

比如用户问:

Netty 和 WebSocket 有什么关系?

第一阶段混合检索可能召回:

1. Netty 是什么
2. WebSocket 是什么
3. Netty 如何支持 WebSocket
4. Java 网络编程基础
5. 服务器部署说明

这些内容都可能有点相关,但真正最有用的是:

Netty 如何支持 WebSocket

rerank 的作用就是把这种更能直接回答问题的 chunk 排到前面。

所以,rerank 不是替代关键词检索和向量检索,而是在它们召回候选结果之后,再做一次更精细的排序。

当然,rerank 也不是没有成本。它通常会增加额外的模型调用或计算开销,所以更适合在知识库变大、候选 chunk 较多、排序质量要求更高时引入。

12.2 基于评测集调整权重

目前关键词权重和向量权重主要是规则设置的。

后续可以通过评测集来调参:

不同权重组合
-> 跑同一批 RAG case
-> 对比 source_hit_rate、keyword_hit_rate、no_answer_accuracy
-> 选择整体效果更好的权重

这样比凭感觉调参数更可靠。

12.3 扩大检索评测集

现在的评测 case 数量还比较少,更像是验证核心链路。

后续应该扩展更多真实问题:

概念解释题
精确事实题
总结归纳题
无依据拒答题
中英文混合技术题
跨 chunk 问题

只有评测集变丰富,才能真正判断检索排序有没有变好。

12.4 完善检索调试能力

项目里已经有 RAG 调试面板,可以看到关键词检索结果、向量检索结果、最终片段和证据判断原因。

后续可以继续展示更细的检索排序信息,比如:

每个 chunk 的关键词分数
每个 chunk 的向量分数
合并后的最终分数
排序前后的变化
证据门控判断原因

这样当某个问题没有命中时,可以更快判断是关键词提取问题、向量召回问题、排序权重问题,还是证据门控问题。

12.5 补充 chunk 切分测试

项目里已经做过第一轮 chunk 切分优化,包括:

Markdown 结构感知切分
长表格按行分组
每个表格 chunk 保留表头
相邻 chunk 补全

后续重点不是从零开始优化 chunk,而是补充测试,保证后续继续改检索和切分策略时,不会破坏已有能力。

比如测试:

Markdown 标题不会被错误合并到上一个 chunk
代码块尽量保持完整
短表格不会被拆开
长表格会按行分组
每个长表格 chunk 都保留表头

13. 总结

这次做 RAG 检索与排序后,我最大的收获是:

RAG 不是“把文档交给大模型回答”,而是一条检索、排序、证据判断、生成回答的工程链路。

关键词检索、向量检索、混合排序各有作用:

关键词检索:
适合技术术语、接口名、文件名等精确匹配。

向量检索:
适合语义相似、表达方式不同的问题。

混合检索:
把两者结合起来,提高召回稳定性。

证据门控:
避免检索结果只是主题相关,但没有真实答案。

如果只看最终回答,很容易忽略中间问题。

真正可靠的 RAG 系统,应该能回答这些问题:

为什么搜到这些 chunk?
为什么这个 chunk 排在前面?
为什么这个问题可以回答?
为什么那个问题必须拒答?
优化后指标有没有变好?

所以,检索与排序不是 RAG 项目里的小细节,而是决定回答质量的核心环节。

对我来说,这次优化也让我从“会调大模型接口”,进一步理解到 AI 应用开发真正要做的是:

把模型能力放进一个可控、可解释、可评测的工程系统里。

14. 项目地址

如果你想看完整项目代码,可以参考:

本文主要涉及的代码文件:

  • X-RAG Agent/rag_service.py
  • X-RAG Agent/knowledge_service.py
  • X-RAG Agent/api.py

项目还在持续完善中,当前版本更偏学习和实习作品,重点是把 RAG 的文档入库、检索排序、证据判断和调试链路跑通。

Logo

更多推荐