检索增强生成(RAG)今年在 AI 应用开发里几乎成了标配,但很多朋友做完第一版 demo 后会发现:用普通向量检索搭的问答系统,在专有名词、ID 编号、长尾问题上总答不准。这背后往往不是大模型选得不好,而是检索链路太单薄。本文从 RAG 的完整流程出发,重点拆解稀疏向量检索、稠密向量检索、RRF 倒数排名融合算法,并附一套可直接超的 Python 代码,帮助你从零搭出一个能落地的问答系统。
1. RAG 是什么:为什么我们需要检索增强生成
RAG 的全称是 Retrieval-Augmented Generation,也就是检索增强生成。它的核心思路非常直观:大模型在回答问题时,不再只依赖自己训练时记住的参数化知识,而是先从外部知识库中检索出与问题相关的内容,把检索结果拼进 Prompt,再交给大模型生成答案。
换句话说,RAG 相当于给大模型开卷考试。模型不用凭记忆硬答,而是先查参考资料,再组织语言输出。这个过程解决了两类很典型的问题:
- 幻觉问题。大模型偶尔会一本正经地编造事实,尤其对实时性较强的信息、企业内部数据、小众领域的专业知识,编造概率更高。RAG 让模型只能基于检索到的文档内容回答,有效降低幻觉。
- 知识滞后问题。大模型的训练数据有时间截止点,无法感知最新发生的事件。RAG 通过外部索引实时更新知识,不需要重新训练模型。
1.1 RAG 的典型应用场景
RAG 的应用场景非常广泛,目前生产中落地较多的包括:
- 企业知识库问答。把内部文档、产品手册、制度规范做成问答机器人,员工可以直接问“报销流程是什么”“某个 API 怎么调用”。
- 私有数据问答。大模型没有见过用户自己的数据,通过 RAG 把数据库、文件、表格内容注入回答链路。
- 客服机器人。结合历史工单、FAQ、商品信息,辅助客服快速回复用户。
- 技术文档助手。开发者文档、SDK 说明、API 手册可以在 IDE 或网页端直接检索问答。
- 学术与法律辅助检索。针对协议原文、论文库、法律法规做定点检索和答案生成,例如针对 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.txt3. RAG 核心检索链路拆解:从文档加载到向量入库
RAG 的离线部分可以概括为一条流水线:文档加载 → 解析 → 分块 → 向量化 → 索引存储 → 建立检索入口。看似简单,但每一步都直接影响检索质量。
3.1 文档加载与解析
文档加载解决的是“数据从哪里来、怎么变成纯文本”的问题。实际项目中的数据源非常多样:
| 数据源类型 | 常见格式 | 常用加载方式 |
|---|---|---|
| 文本文件 | txt、md | 直接读取 |
| 办公文档 | pdf、docx、pptx | PyPDFLoader、Docx2txtLoader |
| 网页 | html、url | WebBaseLoader |
| 数据库 | MySQL、PostgreSQL | SQL 查询后转为文本 |
| 爬虫结果 | 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 的核心思想并不复杂:
- 词频(TF):一个词在文档中出现次数越多,文档与查询越相关。但词频不能线性增长,要加饱和处理,避免某个词重复出现导致得分无限膨胀。
- 逆文档频率(IDF):一个词在越少的文档中出现,说明它越有区分度,权重越高。比如“的”“了”这类停用词在几乎所有文档中出现,IDF 很低。
- 文档长度归一化:更短的文档如果包含关键词,通常比长文档更相关,需要做长度惩罚。
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 模型时,重点关注三点:
- 语言适配。中文场景优先选择在中文语料上训练过的模型,例如
BAAI/bge-large-zh-v1.5、shibing624/text2vec-base-chinese、moka-ai/m3e-base等。 - 向量维度与性能。维度越高,表达力越强,但存储和计算成本也越高。大批量场景下需要权衡。
- 效果验证。不要只信模型榜单,用你自己领域的文档构造测试集,计算检索召回率再决定。
这里还要注意一个问题:检索向量和入库向量必须使用同一个 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 结果 | 向量检索结果 |
|---|---|---|
| 1 | doc_a | doc_c |
| 2 | doc_b | doc_a |
| 3 | doc_d | doc_e |
| 4 | doc_c | doc_b |
| 5 | doc_f | doc_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这个模块有两个细节值得注意:
- 中文分词。BM25 面向英文单词设计,如果直接用字符作为 token,中文词组的语义会被打散。这里引入
jieba分词,把“混合检索”拆成“混合/检索”两个词,提高匹配准确率。 - 过滤零分结果。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 条结果中是否出现至少一条相关文档 | 常用来快速判断检索是否完全失效 |
| MRR | Mean Reciprocal Rank,第一个相关文档排名的倒数平均值 | 关心“第一条相关文档出现在第几位”,适合单文档场景 |
| NDCG | Normalized 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,而是检索召回质量不够。解决办法:
- 打印检索结果,检查排序靠前的文档内容是否相关。
- 尝试增加 top_k,让更多候选进入重排阶段。
- 优化分块策略,让每一块内容更聚焦。
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 生成回答的完整链路。核心收获可以归纳为三点:
- RAG 的检索质量决定了回答质量的上限。检索链路不是“能查到就行”,稀疏检索和稠密检索互为补充,混合检索是生产级的标配。
- RRF 融合算法实现简单、效果稳定。它只依赖排名位置,绕开了不同检索器得分尺度不一致的问题,适合作为混合检索的默认融合方案。
- 工程落地离不开评估。用离线评估集量化检索和生成效果,才能持续迭代优化,而不是永远停留在“试了几个问题感觉还行”的阶段。
下一步如果你想继续深入,可以关注几个方向:
- 查询改写:基于 LLM 的查询改写策略。
- 重排模型:使用 cross-encoder 替代 RRF,把排序精度再推进一步。
- Agentic RAG:让系统自主判断是否需要多轮检索、是否调用数据库或工具。
- 向量数据库对比:Milvus、Qdrant、Elasticsearch 在不同规模下的性能差异。
- RAG 评估框架:使用 RAGAS、TruLens 等工具完成自动化评估。
如果想把这套代码落到你自己的数据集上,建议先保留小规模评估集,修改检索策略后逐步回归对比。只有建立在量化评估基础上的优化,才是真正可持续的优化。