news 2026/9/7 18:38:54

RAG混合检索实战:BM25+稠密向量与RRF融合解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
RAG混合检索实战:BM25+稠密向量与RRF融合解析

检索增强生成(RAG)今年在 AI 应用开发里几乎成了标配,但很多朋友做完第一版 demo 后会发现:用普通向量检索搭的问答系统,在专有名词、ID 编号、长尾问题上总答不准。这背后往往不是大模型选得不好,而是检索链路太单薄。本文从 RAG 的完整流程出发,重点拆解稀疏向量检索、稠密向量检索、RRF 倒数排名融合算法,并附一套可直接超的 Python 代码,帮助你从零搭出一个能落地的问答系统。

1. RAG 是什么:为什么我们需要检索增强生成

RAG 的全称是 Retrieval-Augmented Generation,也就是检索增强生成。它的核心思路非常直观:大模型在回答问题时,不再只依赖自己训练时记住的参数化知识,而是先从外部知识库中检索出与问题相关的内容,把检索结果拼进 Prompt,再交给大模型生成答案。

换句话说,RAG 相当于给大模型开卷考试。模型不用凭记忆硬答,而是先查参考资料,再组织语言输出。这个过程解决了两类很典型的问题:

  • 幻觉问题。大模型偶尔会一本正经地编造事实,尤其对实时性较强的信息、企业内部数据、小众领域的专业知识,编造概率更高。RAG 让模型只能基于检索到的文档内容回答,有效降低幻觉。
  • 知识滞后问题。大模型的训练数据有时间截止点,无法感知最新发生的事件。RAG 通过外部索引实时更新知识,不需要重新训练模型。

1.1 RAG 的典型应用场景

RAG 的应用场景非常广泛,目前生产中落地较多的包括:

  1. 企业知识库问答。把内部文档、产品手册、制度规范做成问答机器人,员工可以直接问“报销流程是什么”“某个 API 怎么调用”。
  2. 私有数据问答。大模型没有见过用户自己的数据,通过 RAG 把数据库、文件、表格内容注入回答链路。
  3. 客服机器人。结合历史工单、FAQ、商品信息,辅助客服快速回复用户。
  4. 技术文档助手。开发者文档、SDK 说明、API 手册可以在 IDE 或网页端直接检索问答。
  5. 学术与法律辅助检索。针对协议原文、论文库、法律法规做定点检索和答案生成,例如针对 3GPP 协议的 RAG,就是让大模型基于通信协议原文回答问题。

1.2 从朴素 RAG 到高级 RAG 再到 Agentic RAG

很多地方会提到“朴素 RAG”“高级 RAG”“Agentic RAG”这三个概念,它们其实代表 RAG 演进的不同阶段:

  • 朴素 RAG(Naive RAG):最基础的“检索-增强-生成”流程,即文档切块、向量化、检索 top-k、拼接 Prompt、生成回答。问题在于检索质量不稳定,对查询词和文档表达差异较大的情况效果差。
  • 高级 RAG(Advanced RAG):在朴素 RAG 基础上增加了查询改写、混合检索、重排、上下文压缩等优化手段,把检索质量进一步提升。
  • Agentic RAG:引入 Agent 思想,让系统能自主判断“是否需要检索”“需要检索几轮”“当前信息够不够回答”,甚至调用多个数据源。RAG 从单次检索变成了一个灵活的决策过程。

本文的代码示例重点落在高级 RAG 的混合检索与融合排序上,因为这是普通开发者最容易上手、收益也最明显的升级点。

2. 环境准备与依赖安装

2.1 技术栈说明

本文示例以 Python 3.9+ 为主要运行环境,核心依赖包括:

  • LangChain:编排文档加载、分块、检索、生成链路。
  • Sentence-Transformers:生成稠密向量嵌入。
  • RankBM25:实现基于 BM25 的稀疏检索。
  • OpenAI SDK 或任一兼容 OpenAI 协议的 SDK:调用大模型生成回答。
  • Chroma 或 Lancedb:作为向量数据库本地存储。本文示例使用 Lancedb,轻量、无需额外起服务。

需要注意的是,上述依赖库版本更新非常快,不同大版本之间 API 差异较大。本文示例代码以常见稳定版本为例,安装时如果遇到接口变更,请以官方文档为准。下面是依赖安装命令:

pip install langchain langchain-community langchain-text-splitters sentence-transformers rank-bm25 lancedb openai python-dotenv

如果你是在国内网络环境下使用 OpenAI 兼容模型,也可以替换为通义千问、DeepSeek、智谱等模型,只要服务商提供 OpenAI 兼容接口即可,代码里的base_url对应修改。

2.2 示例项目结构

为了便于阅读,我把整个项目拆成标准目录结构:

rag_qa/ ├── data/ │ └── knowledge.txt # 待检索的知识文档 ├── src/ │ ├── __init__.py │ ├── loader.py # 文档加载与分块 │ ├── embeddings.py # 稠密向量嵌入 │ ├── hybrid_search.py # 混合检索 + RRF 融合 │ ├── llm_qa.py # 大模型生成回答 │ └── main.py # 主流程 ├── .env # API Key 等敏感配置 └── requirements.txt

3. RAG 核心检索链路拆解:从文档加载到向量入库

RAG 的离线部分可以概括为一条流水线:文档加载 → 解析 → 分块 → 向量化 → 索引存储 → 建立检索入口。看似简单,但每一步都直接影响检索质量。

3.1 文档加载与解析

文档加载解决的是“数据从哪里来、怎么变成纯文本”的问题。实际项目中的数据源非常多样:

数据源类型常见格式常用加载方式
文本文件txt、md直接读取
办公文档pdf、docx、pptxPyPDFLoader、Docx2txtLoader
网页html、urlWebBaseLoader
数据库MySQL、PostgreSQLSQL 查询后转为文本
爬虫结果json、csv自定义解析

这一层的关键在于解析完整性。PDF 多列排版、表格、扫描件 OCR 质量差,都会导致解析后的文本错乱。如果解析文本本身是乱的,后续检索再好也没用。

3.2 文本分块

向量检索的核心是“相似度计算”,而相似度计算需要一个一个文本单元独立进行。整篇文档直接做向量化,计算量和精度都不可控,所以必须分块。

分块策略一般考虑三个参数:

  • chunk_size:每个块的字符数或 token 数。
  • chunk_overlap:相邻块之间的重叠字符数。
  • separator:分隔符,比如换行符、句号、Markdown 标题。

分块过小,语义不完整;分块过大,向量囊括太多无关内容,检索精度下降。我的经验是:中文场景下,chunk_size 取 300-500 字符、overlap 取 50-100 字符,是一个比较稳妥的起点。如果文档结构清晰(比如每个章节都有标题),优先按结构分块,再在块内做长度裁剪。

3.3 向量化与索引

向量化就是把文本变成一串数字。这里要区分两种思路:

  • 稠密向量:用 Embedding 模型把语义压缩到一个固定维度向量里,语义相近的文本向量距离更近。
  • 稀疏向量:基于词频或词权重构建高维稀疏向量,维度可以非常大,但大部分位置是 0,只保留关键词维度有值。

本文后面会分别展开这两种方式。向量化完成后,把向量和原始文本、元数据一起写入向量数据库,构建可检索的索引。索引里至少应该包含:原始文本、元数据、向量列、主键。

3.4 检索与重排

在线查询时,用户问题也会被向量化,然后在索引中找到最相似的若干条记录。但“最相似”不等于“最相关”。为了让最终进入 LLM 的信息更精准,可以在检索后追加一个重排环节,把 top-20 的结果压缩到 top-5。

重排的方式有很多种:可以用 cross-encoder 重新打分,也可以用 RRF 这类基于排序位置的融合方法。混合检索 + RRF 就是在这一层发挥作用的。

4. 稀疏向量检索:当关键词比语义更重要

4.1 稀疏向量是什么

稀疏向量(Sparse Vector)是指大部分维度为 0、只有少数维度有非零值的向量。在文本场景中,每个维度通常代表一个词,维度上的值代表这个词在文档中的重要程度。由于词汇量很大,向量维度能到几十万甚至上百万,但一篇文档实际出现的词可能只有几百个,所以绝大多数位置是 0。

这种向量的优点是可解释性强、精准匹配关键词。比如查询“RAG 知识库部署”,如果文档里出现了“RAG”“知识库”“部署”这些词,即使文档和查询的语义表达不完全一致,也能通过词项匹配被检索出来。

4.2 BM25:经典稀疏检索算法

提到稀疏检索,最先要了解的是 BM25。BM25 是一种基于词频和逆文档频率的排序函数,是传统全文搜索引擎(如 Elasticsearch 默认相关度算法)的核心。

BM25 的核心思想并不复杂:

  1. 词频(TF):一个词在文档中出现次数越多,文档与查询越相关。但词频不能线性增长,要加饱和处理,避免某个词重复出现导致得分无限膨胀。
  2. 逆文档频率(IDF):一个词在越少的文档中出现,说明它越有区分度,权重越高。比如“的”“了”这类停用词在几乎所有文档中出现,IDF 很低。
  3. 文档长度归一化:更短的文档如果包含关键词,通常比长文档更相关,需要做长度惩罚。

BM25 特别适合处理专有名词、编号、型号、代码关键字。比如用户查询订单号"SO-2024-001",如果这段知识库里确实存在这个编号,BM25 可以精确匹配,而普通向量检索很可能因为语义编码把注意力放在“订单”两个字上,忽略后面的编号。

4.3 SPLADE:学习式稀疏向量

除了 BM25,近年来也出现了很多学习式稀疏向量模型,代表作是 SPLADE。SPLADE 通过 Transformer 模型预测每个词在文档中的重要性权重,把文本转成稀疏向量。相比 BM25,SPLADE 能引入一定程度的语义扩展,同时保留稀疏向量可解释、高效检索的优点。

不过在纯工程落地中,BM25 已经能解决大部分问题。本文代码使用 BM25 作为稀疏检索实现,重点展示混合检索思路,如果你想换成 SPLADE,只要把稀疏检索结果接进融合层即可。

4.4 稀疏检索的适用场景与局限

适用场景:

  • 查询里包含专有名词、编号、英文缩写、代码片段。
  • 领域术语在文档中频繁出现,且表达高度一致。
  • 需要快速解释“为什么返回这条结果”,方便审计和 debug。

局限:

  • 对“语义相同但表达不同”的查询无能为力。比如用户问“怎么取消订单”,而文档里写的是“申请退款流程”,两者没有公共关键词,BM25 很难命中。
  • 对词序不敏感,长句理解有限。

所以,稀疏检索不能单独扛起 RAG 的全部召回任务,它需要和稠密向量检索配合。

5. 稠密向量检索:用 Embedding 捕获语义

5.1 稠密向量原理

稠密向量(Dense Vector)是 Embedding 模型输出的固定长度向量,常见维度有 384、768、1024、1536 等。模型通过大规模语料训练,把文本映射到向量空间,语义相近的文本在空间中距离更近。

比如:

  • “怎么退钱”和“如何申请退款”在字面上不同,但语义相近。
  • 两类文本的稠密向量夹角很小,余弦相似度很高,因此能匹配上。

5.2 如何选择 Embedding 模型

选择 Embedding 模型时,重点关注三点:

  1. 语言适配。中文场景优先选择在中文语料上训练过的模型,例如BAAI/bge-large-zh-v1.5shibing624/text2vec-base-chinesemoka-ai/m3e-base等。
  2. 向量维度与性能。维度越高,表达力越强,但存储和计算成本也越高。大批量场景下需要权衡。
  3. 效果验证。不要只信模型榜单,用你自己领域的文档构造测试集,计算检索召回率再决定。

这里还要注意一个问题:检索向量和入库向量必须使用同一个 Embedding 模型。如果入库用了模型 A,查询时用模型 B,两者向量空间不一致,相似度计算会完全失真。

5.3 稠密检索的局限

稠密检索也不是万能的。它最明显的短板是:

  • 专有名词、编号类查询容易跑偏。一个向量把所有语义压缩成几十或几百个维度后,编号这种低频但精确的信息往往被弱化。
  • 对长尾词、拼写变体不敏感。如果知识库里的词和查询词形态差异大,模型没见过这种写法,可能输出一个偏离的向量。
  • 可解释性差。你很难说清楚“为什么这两段文本相似”。

因此,生产级 RAG 里,稀疏检索和稠密检索通常一起使用,这就是我们常说的混合检索。

6. 混合检索与 RRF 倒数排名融合:一线工程里真正常用的方案

6.1 为什么需要混合检索

先看一个真实场景。假设你的知识库里有一段话:

“用户在完成实名认证后,可以通过个人中心-账户设置-注销入口发起注销申请。”

用户提问:

“我想把账号删掉怎么办?”

  • 稠密检索能匹配“注销”“账户”语义,基本可以召回这段话。
  • 但如果知识库文档里混杂了大量“账号申诉”“账号找回”的内容,稠密检索可能把相似语义的内容都拉回来,排序不够精准。

再假设用户提问:

“请求参数里的 app_id 为空时,服务端返回错误码 40010,如何排查?”

这句话里有明确的专有名词app_id和错误码40010。稠密向量很可能把它们当成普通 token 融入整体语义,而稀疏检索可以精确命中“app_id”“40010”这两个高区分度词。

混合检索就是把两种结果合并,利用各自优势,提升整体召回率和排序准确率。

6.2 RRF 倒数排名融合算法原理

RRF 的全称是 Reciprocal Rank Fusion,即倒数排名融合。它的核心公式非常简单:

score(d) = Σ (1 / (k + rank_i(d)))

其中:

  • rank_i(d)是文档d在第i个检索系统中的排名。
  • k是一个常数,经验上通常取 60,用来平滑排名差异,避免排名靠前的文档得分占比过高。

RRF 的核心思想是:不依赖具体得分,只看每个文档在各自检索结果中的排序位置。不同检索系统的得分尺度可能差别很大,比如 BM25 得分可能从 0 到 100,而向量相似度得分可能只在 0 到 1 之间。如果直接把得分相加,向量相似度的影响会完全被淹没。RRF 把得分统一成排名,巧妙地避开了尺度问题。

举例说明,假设 BM25 和向量检索各返回 5 条结果:

排名BM25 结果向量检索结果
1doc_adoc_c
2doc_bdoc_a
3doc_ddoc_e
4doc_cdoc_b
5doc_fdoc_d

根据 RRF 公式(k=60),计算得分:

  • doc_a:在 BM25 排名第 1,得分 1/61;在向量检索排名第 2,得分 1/62。总分约 0.0325。
  • doc_c:在 BM25 排名第 4,得分 1/64;在向量检索排名第 1,得分 1/61。总分约 0.0320。
  • doc_b:在 BM25 排名第 2,得分 1/62;在向量检索排名第 4,得分 1/64。总分约 0.0317。

最终排序为 doc_a > doc_c > doc_b,说明 RRF 对“在两个系统中都排名靠前”的文档更友好,而不是盲目相信某一个检索器的最优结果。

6.3 RRF 的代码实现

RRF 本身的代码很短,甚至可以不用框架,自己实现一个:

def rrf_fusion(ranked_results: list[list[str]], k: int = 60) -> dict[str, float]: """ 对多个检索系统的排名结果做 RRF 融合。 :param ranked_results: 每个检索系统返回的文档 ID 列表,按排名从高到低排列 :param k: 平滑常数,常见取值为 60 :return: 文档 ID 到融合得分的映射 """ fused_scores = {} for ranked_list in ranked_results: for rank, doc_id in enumerate(ranked_list): # rank 从 1 开始计算 fused_scores[doc_id] = fused_scores.get(doc_id, 0) + 1 / (k + rank + 1) return fused_scores if __name__ == "__main__": bm25_result = ["doc_a", "doc_b", "doc_d", "doc_c", "doc_f"] dense_result = ["doc_c", "doc_a", "doc_e", "doc_b", "doc_d"] final_scores = rrf_fusion([bm25_result, dense_result], k=60) sorted_docs = sorted(final_scores.items(), key=lambda x: x[1], reverse=True) for doc_id, score in sorted_docs: print(f"{doc_id}: {score:.4f}")

运行这段代码,输出结果为:

doc_a: 0.0325 doc_c: 0.0320 doc_b: 0.0317 doc_d: 0.0161 doc_f: 0.0156 doc_e: 0.0164

这里 doc_e 在 BM25 里没有出现,只在向量检索中出现,所以它的得分只来自一路,整体排名靠后。这就是 RRF“鼓励文档在多个系统中都有较好表现”的特点。

6.4 为什么不用简单加权求和

很多初学者会问:RRF 和加权求和(weighted sum)有什么区别?加权求和的问题在于两路检索得分不可比,具体来说:

  • BM25 得分受文档长度、词频影响,数值范围不稳定。
  • 向量余弦相似度稳定在 [-1, 1] 区间,但分布在不同数据集上差异很大。
  • 加权求和需要对两路得分做归一化或标准化,而归一化方式本身就需要调参。

RRF 只使用排名信息,对尺度不敏感,实现成本低,鲁棒性强。这也是为什么 Elasticsearch、Milvus 等产品在做混合检索时默认推荐 RRF 的原因。

7. 完整实战:基于 LangChain 的 RAG 问答系统代码

前面概念梳理完了,下面进入完整代码实战。我们以一个本地知识库问答系统为例,实现:文档加载 → 分块 → 稠密向量库构建 → BM25 稀疏索引 → 混合检索 → RRF 融合 → LLM 生成回答。

7.1 数据准备

先在data/knowledge.txt里放一段小知识库,内容可以随意换成你自己的产品文档:

RAG 系统支持混合检索功能。 混合检索包括稀疏向量检索和稠密向量检索两部分。 稀疏向量检索使用 BM25 算法,适合匹配专有名词和编号。 稠密向量检索使用 Embedding 模型,适合匹配语义相近的文本。 RRF 倒数排名融合算法可以将多路检索结果合并排序。 如果在检索结果中没有找到相关内容,系统会提示用户补充信息。 知识库需要定期更新,保证数据时效性和准确性。 在部署时,建议开启日志和监控,便于问题排查。

7.2 编写文档加载与分块模块

文件路径:src/loader.py

from langchain_community.document_loaders import TextLoader from langchain_text_splitters import RecursiveCharacterTextSplitter def load_documents(file_path: str): """加载文本文件并返回 Document 列表。""" loader = TextLoader(file_path, encoding="utf-8") documents = loader.load() return documents def split_documents(documents, chunk_size: int = 200, chunk_overlap: int = 50): """将文档切分为固定大小的文本块。""" text_splitter = RecursiveCharacterTextSplitter( chunk_size=chunk_size, chunk_overlap=chunk_overlap, separators=["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""], ) chunks = text_splitter.split_documents(documents) return chunks

这个模块里有一个细节:separators列表的顺序很重要。LangChain 会优先使用前面的分隔符切分,尽量保留语义完整的段落;只有当切分后仍超过 chunk_size 时,才会尝试后面的分隔符。中文场景下,优先按句号和分号切分,避免把一句话截断。

7.3 编写向量化与稠密索引模块

文件路径:src/embeddings.py

from langchain_community.embeddings import HuggingFaceEmbeddings from lancedb.pydantic import Vector import lancedb def build_dense_index(chunks, db_path: str = "./lancedb", table_name: str = "knowledge"): """ 使用本地 HuggingFace Embedding 模型生成稠密向量,并写入 LanceDB。 这里以 BAAI/bge-small-zh-v1.5 为例,你可以按需替换其他模型。 """ embedding_model = HuggingFaceEmbeddings( model_name="BAAI/bge-small-zh-v1.5", encode_kwargs={"normalize_embeddings": True}, ) texts = [chunk.page_content for chunk in chunks] metadatas = [chunk.metadata for chunk in chunks] embeddings = embedding_model.embed_documents(texts) db = lancedb.connect(db_path) if table_name in db.table_names(): db.drop_table(table_name) data = [] for i, text in enumerate(texts): data.append({ "id": f"chunk_{i}", "text": text, "vector": embeddings[i], "metadata": metadatas[i], }) table = db.create_table(table_name, data=data) return table, embedding_model

这里选择bge-small-zh-v1.5作为本地 Embedding 模型,好处是无需额外付费、离网可用,适合演示。如果你的机器没有 GPU,第一次运行会下载模型权重,速度会慢一些,但模型不大,一般几十 MB 到几百 MB。

7.4 编写混合检索与 RRF 融合模块

文件路径:src/hybrid_search.py

from rank_bm25 import BM25Okapi import jieba def tokenize_chinese(text: str) -> list[str]: """ 中文分词。BM25 需要词级别的 token, 直接按字切分效果差,这里使用 jieba 做中文分词。 """ return list(jieba.cut(text)) class HybridSearcher: def __init__(self, docs_texts: list[str], table, embedding_model): """ :param docs_texts: 所有分块后的文本列表,与向量库中的顺序一一对应 :param table: LanceDB 表对象 :param embedding_model: 与建库时一致的 Embedding 模型 """ self.docs_texts = docs_texts self.table = table self.embedding_model = embedding_model # 构建 BM25 稀疏索引 tokenized_corpus = [tokenize_chinese(doc) for doc in docs_texts] self.bm25 = BM25Okapi(tokenized_corpus) def dense_search(self, query: str, top_k: int = 10): """稠密向量检索。""" query_embedding = self.embedding_model.embed_query(query) results = self.table.search(query_embedding).limit(top_k).to_list() return [item["id"] for item in results] def sparse_search(self, query: str, top_k: int = 10): """稀疏检索(BM25)。""" tokenized_query = tokenize_chinese(query) scores = self.bm25.get_scores(tokenized_query) sorted_idx = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True) # 只保留分数大于 0 的结果,避免噪声 filtered_idx = [i for i in sorted_idx[: top_k * 3] if scores[i] > 0] return [f"chunk_{i}" for i in filtered_idx[:top_k]] @staticmethod def rrf_fusion(ranked_results: list[list[str]], k: int = 60) -> dict[str, float]: """RRF 融合多路检索结果。""" fused_scores = {} for ranked_list in ranked_results: for rank, doc_id in enumerate(ranked_list): fused_scores[doc_id] = fused_scores.get(doc_id, 0) + 1 / (k + rank + 1) return fused_scores def hybrid_search(self, query: str, top_k: int = 5): """混合检索并返回融合排序后的文档 ID。""" dense_ids = self.dense_search(query, top_k=top_k * 2) sparse_ids = self.sparse_search(query, top_k=top_k * 2) # 对每条文档建立 ID 到文本的映射 id_to_text = {f"chunk_{i}": self.docs_texts[i] for i in range(len(self.docs_texts))} fused_scores = self.rrf_fusion([dense_ids, sparse_ids]) sorted_ids = sorted(fused_scores.items(), key=lambda x: x[1], reverse=True) results = [] for doc_id, score in sorted_ids[:top_k]: results.append({ "id": doc_id, "text": id_to_text.get(doc_id, ""), "rrf_score": score, }) return results

这个模块有两个细节值得注意:

  1. 中文分词。BM25 面向英文单词设计,如果直接用字符作为 token,中文词组的语义会被打散。这里引入jieba分词,把“混合检索”拆成“混合/检索”两个词,提高匹配准确率。
  2. 过滤零分结果。BM25 的get_scores会对没有命中词项的文档返回 0,如果不加过滤,很多无关文档会被拉进候选集,影响 RRF 的排序效果。

7.5 编写大模型生成回答模块

文件路径:src/llm_qa.py

from openai import OpenAI class LLMQA: def __init__(self, api_key: str, base_url: str = None, model: str = "gpt-4o-mini"): """ :param api_key: 大模型服务的 API Key :param base_url: OpenAI 兼容接口地址,国内模型或中转服务可自定义 :param model: 模型名称,例如 gpt-4o-mini、qwen-plus """ self.client = OpenAI(api_key=api_key, base_url=base_url) self.model = model def generate(self, question: str, context_docs: list[dict]) -> str: """ 基于检索到的文档内容生成回答。 如果检索结果为空或与问题无关,不要强行编造答案。 """ context = "\n---\n".join([doc["text"] for doc in context_docs]) system_prompt = ( "你是一个严谨的问答助手。请只基于给定的参考文档回答问题," "如果参考文档中没有相关信息,请明确回答“知识库中未找到相关信息”," "不要编造内容。" ) user_prompt = f""" 参考文档如下: {context} 用户问题:{question} 请基于参考文档生成简洁、准确的回答。 """ response = self.client.chat.completions.create( model=self.model, messages=[ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt}, ], temperature=0.2, ) return response.choices[0].message.content

这里有几个工程细节:

  • temperature=0.2建议保持较低值,问答场景需要忠实原文,不需要太多创造性。
  • Prompt 里明确要求“参考文档没有相关信息时不要编造”,这是减少幻觉的关键一步。
  • 如果你的模型服务没有提供base_url,可以保持默认值,也就是 OpenAI 官方接口。

7.6 主流程串联:完整的问答系统

文件路径:src/main.py

import os from dotenv import load_dotenv from loader import load_documents, split_documents from embeddings import build_dense_index from hybrid_search import HybridSearcher from llm_qa import LLMQA load_dotenv() DOC_PATH = "data/knowledge.txt" DB_PATH = "./lancedb" TABLE_NAME = "knowledge" def main(): # 1. 加载并切分文档 docs = load_documents(DOC_PATH) chunks = split_documents(docs, chunk_size=200, chunk_overlap=50) texts = [chunk.page_content for chunk in chunks] print(f"文档加载完成,共切分 {len(texts)} 个文本块") # 2. 构建稠密向量索引 table, embedding_model = build_dense_index(chunks, db_path=DB_PATH, table_name=TABLE_NAME) # 3. 构建混合检索器 searcher = HybridSearcher(texts, table, embedding_model) # 4. 初始化 LLM qa = LLMQA( api_key=os.getenv("OPENAI_API_KEY"), base_url=os.getenv("OPENAI_BASE_URL"), model=os.getenv("MODEL_NAME", "gpt-4o-mini"), ) # 5. 进入交互问答 print("RAG 问答系统已启动,输入 exit 退出。") while True: question = input("\n请输入问题:").strip() if question.lower() in ("exit", "quit"): break # 混合检索 context_docs = searcher.hybrid_search(question, top_k=5) if not context_docs: print("未检索到相关文档,请更换问法或补充知识库。") continue print("\n=== 检索到的参考文档 ===") for i, doc in enumerate(context_docs): print(f"[{i + 1}] rrf_score={doc['rrf_score']:.4f}") print(doc["text"]) print() # 生成回答 answer = qa.generate(question, context_docs) print("=== 回答 ===") print(answer) if __name__ == "__main__": main()

7.7 运行与验证

.env文件中配置:

OPENAI_API_KEY=你的APIKey OPENAI_BASE_URL=https://api.openai.com/v1 MODEL_NAME=gpt-4o-mini

然后运行:

python src/main.py

预期效果:

  • 输入“RRF 算法是什么”,系统会从知识库中检索到包含“RRF 倒数排名融合算法”的文本块。
  • 输入“如何匹配专有名词”,BM25 会优先召回包含“专有名词”“BM25 算法”的文本块,稠密向量也会同步召回语义相近内容,RRF 融合后排序更稳定。
  • 输入“北京明天天气怎么样”,知识库中没有相关内容,LLM 会回答“知识库中未找到相关信息”,而不是编造天气。

输出示例:

=== 检索到的参考文档 === [1] rrf_score=0.0323 RRF 倒数排名融合算法可以将多路检索结果合并排序。 [2] rrf_score=0.0317 混合检索包括稀疏向量检索和稠密向量检索两部分。 [3] rrf_score=0.0156 稀疏向量检索使用 BM25 算法,适合匹配专有名词和编号。 === 回答 === RRF(Reciprocal Rank Fusion)是一种将多路检索结果合并排序的算法,它根据文档在不同检索系统中的排名位置计算融合得分,避免不同检索系统的得分尺度不一致问题。

8. RAG 效果评估:知识库指标到底怎么理解

很多开发者在完成 RAG 系统后,只会“人工试几个问题”,这远远不够。要把系统做扎实,必须建立一套可量化的评估体系。RAG 评估通常分为检索质量和生成质量两部分。

8.1 检索质量指标

检索质量衡量的是“检索器能不能把正确的文档排到前面”。常用指标有:

指标全称/含义理解方法
Recall@K前 K 条结果中命中的相关文档比例目标是“真正有用的内容有没有被捞出来”,不关心顺序
Hit Rate前 K 条结果中是否出现至少一条相关文档常用来快速判断检索是否完全失效
MRRMean Reciprocal Rank,第一个相关文档排名的倒数平均值关心“第一条相关文档出现在第几位”,适合单文档场景
NDCGNormalized Discounted Cumulative Gain,归一化折损累计增益关心整个排序列表的质量,相关文档越靠前得分越高

这些指标可以在离线阶段用人工标注的问答测试集计算。比如构造 100 个问题,每个问题标注正确的文档 ID,然后逐个查询,统计 Recall@5、MRR 等指标。

8.2 生成质量指标

生成质量衡量的是“LLM 根据检索结果生成的回答好不好”。常见指标包括:

  • 忠实度(Faithfulness):回答中的每个事实是否都能从参考文档中找到依据。这是 RAG 最重要的指标,能直接反映幻觉程度。
  • 答案相关性(Answer Relevance):回答是否对用户问题有帮助,有没有答非所问。
  • 上下文相关性(Context Relevance):检索回来的文档是不是真的用上了,还是被 LLM 忽略了。

在工程上,可以用另一套 LLM 打分,也可以人工抽检。但需要注意,LLM-as-Judge 本身也有偏差,关键问题仍建议人工裁决。

8.3 打造评估回归集

我建议从小规模开始,准备 50-100 条问答对,覆盖三类情况:

  • 简单知识问答:答案直接存在于某一段文档中。
  • 多文档组合问答:答案需要从两段以上文档中归纳。
  • 拒答场景:知识库里没有相关内容,看系统能否正确拒绝。

每次修改检索策略、分块参数、Embedding 模型后,都跑一遍评估集,观察指标变化。这样才能从“感觉有效果”变成“确实有效果”。

9. 常见问题与排查思路

9.1 故障现象速查表

问题现象常见原因解决思路
向量库查询时报维度不匹配建库与查询时用了不同的 Embedding 模型统一为同一模型,删除旧库重建
BM25 检索结果为空中文未分词,或文档太短没有公共词项引入 jieba 分词,检查分词效果
混合检索结果不如单路检索top_k 候选太少,或 RRF 结果被噪声文档干扰增大候选集,过滤 BM25 零分项,调整 k 值
LLM 回答出现幻觉Prompt 未限制来源,或检索结果为空时仍生成强制要求只依据参考文档,无结果时直接拒答
回答基于检索到的无关文档分块过大,块内语义不聚焦减小 chunk_size,增加 overlap,优化分块策略
系统响应慢Embedding 模型过大,或检索包含太多候选换更小的模型,优化向量索引,加缓存
文档解析后内容错乱PDF 多列/扫描件/表格解析不完整更换解析器,做 OCR 预处理
新文档更新后检索不到向量库没有增量更新或索引过期重建或增量更新索引,记录版本号

9.2 重点排查案例

维度不匹配错误

这是最典型的错误。报错内容通常是:

Dimension mismatch: expected 768, got 1024

原因基本就是建库和查询时的 Embedding 模型不一致。比如建库时用bge-base-zh-v1.5(768 维),查询时换成了bge-large-zh-v1.5(1024 维)。解决办法是删除原有向量库,统一模型后重新建库。

检索到了内容但 LLM 回答仍然不对

先检查检索到的文本块是否真的包含答案。如果文本块里没有答案,那问题不在 LLM,而是检索召回质量不够。解决办法:

  1. 打印检索结果,检查排序靠前的文档内容是否相关。
  2. 尝试增加 top_k,让更多候选进入重排阶段。
  3. 优化分块策略,让每一块内容更聚焦。

RRF 融合后效果反而变差

RRF 不是万能的。如果稀疏检索结果大部分是零分噪声文档,融合后排序会被带偏。建议在取 BM25 结果时过滤掉得分 0 的文档,同时让每路检索的候选集大于最终 top_k,给 RRF 留出融合空间。

10. 最佳实践与工程建议

10.1 查询改写:让问题更利于检索

原始用户问题往往比较口语化、表述模糊,直接拿去检索效果不好。可以在检索前增加一个“查询改写”步骤,用 LLM 把问题改写得更适合检索。例如:

  • 用户问:“这东西能不能退啊?”
  • 改写后:“该商品的退货政策是什么?可以退货吗?”

改写后的查询更接近知识库的表达方式,能明显提升检索命中率。

10.2 分块策略:按结构优先,而不是一味调参

如果文档本身有章节目录,推荐先把文档按标题结构拆成多个上下文块,再对超长的块做二次切分。LangChain 的MarkdownHeaderTextSplitter就是为这种场景设计的。

10.3 元数据过滤:缩小检索范围

向量数据库普遍支持按元数据过滤。比如不同部门的知识文档可以打上department标签,问答时根据用户组织信息强制过滤,减少跨域干扰。这是一个成本低、收益高的优化手段。

10.4 缓存与索引更新

线上系统建议对高频问题做结果缓存。LLM 调用成本高、延迟大,如果短时间内遇到大量相同或相似问题,直接从缓存返回结果能显著降低压力。

索引更新不要做成全量删除重建,尽量采用“增量写入 + 定期清理”的策略。对删除的数据要同步从向量库和稀疏索引中移除,避免检索到脏数据。

10.5 安全与权限边界

RAG 系统接入企业内部知识库时,必须考虑权限模型。不能让所有用户检索到所有文档,否则会造成严重的数据泄露。建议在检索层增加权限过滤条件,例如按用户角色、部门、文档密级做白名单控制。涉及生产环境变更时,一定要先在测试环境验证,并保留数据备份。

10.6 从 Demo 到生产的几个关键动作

  • 建立一个离线评估集,让优化有据可依。
  • 关注检索延迟和吞吐量,必要时引入异步处理。
  • 记录每次查询的检索结果、Top 文档、模型回答,便于复盘和 debug。
  • 监控 LLM 调用成本,设置预算告警。
  • 对知识库中敏感信息做脱敏或权限隔离处理。

11. 总结与下一步学习方向

本文从 RAG 的基础概念出发,系统拆解了文档加载、分块、向量化、索引构建、混合检索、RRF 融合、LLM 生成回答的完整链路。核心收获可以归纳为三点:

  1. RAG 的检索质量决定了回答质量的上限。检索链路不是“能查到就行”,稀疏检索和稠密检索互为补充,混合检索是生产级的标配。
  2. RRF 融合算法实现简单、效果稳定。它只依赖排名位置,绕开了不同检索器得分尺度不一致的问题,适合作为混合检索的默认融合方案。
  3. 工程落地离不开评估。用离线评估集量化检索和生成效果,才能持续迭代优化,而不是永远停留在“试了几个问题感觉还行”的阶段。

下一步如果你想继续深入,可以关注几个方向:

  • 查询改写:基于 LLM 的查询改写策略。
  • 重排模型:使用 cross-encoder 替代 RRF,把排序精度再推进一步。
  • Agentic RAG:让系统自主判断是否需要多轮检索、是否调用数据库或工具。
  • 向量数据库对比:Milvus、Qdrant、Elasticsearch 在不同规模下的性能差异。
  • RAG 评估框架:使用 RAGAS、TruLens 等工具完成自动化评估。

如果想把这套代码落到你自己的数据集上,建议先保留小规模评估集,修改检索策略后逐步回归对比。只有建立在量化评估基础上的优化,才是真正可持续的优化。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/5 14:29:45

YOLOv8安全帽检测实战:从模型训练到边缘部署的完整路径

简介:本资源是一个面向人工智能初学者与工程实践者的工地安全帽智能监管系统实战项目,聚焦计算机视觉在安全生产领域的落地应用,解决建筑工地人工巡检效率低、漏检率高等痛点。压缩包共109个文件,包含29个Python源码(含…

作者头像 李华
网站建设 2026/9/4 17:09:02

顺丰科技运维笔试深度解析:从Linux到Kubernetes的考点与实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/6 10:44:49

游戏引擎×计算机视觉交叉岗笔试题解析:从渲染管线到实时算法

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/5 23:48:37

无人机算法项目失败复盘:五大模块工程问题与避坑指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/4 17:06:36

Job System:当游戏引擎学会“众包“思维

开篇:一个厨房的比喻 想象你是一家餐厅的主厨,今晚要准备100份套餐。 笨办法:你一个人从头做到尾——切菜、炒菜、装盘、上桌,一份接一份。哪怕你是米其林大厨,效率也高不到哪去。 聪明办法:你雇了5个帮厨。你把任务拆解——“你专门切菜”、“你专门炒菜”、“你专门…

作者头像 李华
网站建设 2026/9/6 6:44:12

UE5材质工作流核心:从PBR基础到材质实例化实用指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华