ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

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

RAG混合检索实战:BM25+稠密向量与RRF融合解析 检索增强生成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 演进的不同阶段朴素 RAGNaive RAG最基础的“检索-增强-生成”流程即文档切块、向量化、检索 top-k、拼接 Prompt、生成回答。问题在于检索质量不稳定对查询词和文档表达差异较大的情况效果差。高级 RAGAdvanced 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、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 的核心思想并不复杂词频TF一个词在文档中出现次数越多文档与查询越相关。但词频不能线性增长要加饱和处理避免某个词重复出现导致得分无限膨胀。逆文档频率IDF一个词在越少的文档中出现说明它越有区分度权重越高。比如“的”“了”这类停用词在几乎所有文档中出现IDF 很低。文档长度归一化更短的文档如果包含关键词通常比长文档更相关需要做长度惩罚。BM25 特别适合处理专有名词、编号、型号、代码关键字。比如用户查询订单号SO-2024-001如果这段知识库里确实存在这个编号BM25 可以精确匹配而普通向量检索很可能因为语义编码把注意力放在“订单”两个字上忽略后面的编号。4.3 SPLADE学习式稀疏向量除了 BM25近年来也出现了很多学习式稀疏向量模型代表作是 SPLADE。SPLADE 通过 Transformer 模型预测每个词在文档中的重要性权重把文本转成稀疏向量。相比 BM25SPLADE 能引入一定程度的语义扩展同时保留稀疏向量可解释、高效检索的优点。不过在纯工程落地中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 结果向量检索结果1doc_adoc_c2doc_bdoc_a3doc_ddoc_e4doc_cdoc_b5doc_fdoc_d根据 RRF 公式k60计算得分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], k60) sorted_docs sorted(final_scores.items(), keylambda x: x[1], reverseTrue) 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.pyfrom langchain_community.document_loaders import TextLoader from langchain_text_splitters import RecursiveCharacterTextSplitter def load_documents(file_path: str): 加载文本文件并返回 Document 列表。 loader TextLoader(file_path, encodingutf-8) documents loader.load() return documents def split_documents(documents, chunk_size: int 200, chunk_overlap: int 50): 将文档切分为固定大小的文本块。 text_splitter RecursiveCharacterTextSplitter( chunk_sizechunk_size, chunk_overlapchunk_overlap, separators[\n\n, \n, 。, , , , , , ], ) chunks text_splitter.split_documents(documents) return chunks这个模块里有一个细节separators列表的顺序很重要。LangChain 会优先使用前面的分隔符切分尽量保留语义完整的段落只有当切分后仍超过 chunk_size 时才会尝试后面的分隔符。中文场景下优先按句号和分号切分避免把一句话截断。7.3 编写向量化与稠密索引模块文件路径src/embeddings.pyfrom 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_nameBAAI/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: fchunk_{i}, text: text, vector: embeddings[i], metadata: metadatas[i], }) table db.create_table(table_name, datadata) return table, embedding_model这里选择bge-small-zh-v1.5作为本地 Embedding 模型好处是无需额外付费、离网可用适合演示。如果你的机器没有 GPU第一次运行会下载模型权重速度会慢一些但模型不大一般几十 MB 到几百 MB。7.4 编写混合检索与 RRF 融合模块文件路径src/hybrid_search.pyfrom 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)), keylambda i: scores[i], reverseTrue) # 只保留分数大于 0 的结果避免噪声 filtered_idx [i for i in sorted_idx[: top_k * 3] if scores[i] 0] return [fchunk_{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_ktop_k * 2) sparse_ids self.sparse_search(query, top_ktop_k * 2) # 对每条文档建立 ID 到文本的映射 id_to_text {fchunk_{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(), keylambda x: x[1], reverseTrue) 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.pyfrom 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_keyapi_key, base_urlbase_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( modelself.model, messages[ {role: system, content: system_prompt}, {role: user, content: user_prompt}, ], temperature0.2, ) return response.choices[0].message.content这里有几个工程细节temperature0.2建议保持较低值问答场景需要忠实原文不需要太多创造性。Prompt 里明确要求“参考文档没有相关信息时不要编造”这是减少幻觉的关键一步。如果你的模型服务没有提供base_url可以保持默认值也就是 OpenAI 官方接口。7.6 主流程串联完整的问答系统文件路径src/main.pyimport 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_size200, chunk_overlap50) texts [chunk.page_content for chunk in chunks] print(f文档加载完成共切分 {len(texts)} 个文本块) # 2. 构建稠密向量索引 table, embedding_model build_dense_index(chunks, db_pathDB_PATH, table_nameTABLE_NAME) # 3. 构建混合检索器 searcher HybridSearcher(texts, table, embedding_model) # 4. 初始化 LLM qa LLMQA( api_keyos.getenv(OPENAI_API_KEY), base_urlos.getenv(OPENAI_BASE_URL), modelos.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_k5) 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_URLhttps://api.openai.com/v1 MODEL_NAMEgpt-4o-mini然后运行python src/main.py预期效果输入“RRF 算法是什么”系统会从知识库中检索到包含“RRF 倒数排名融合算法”的文本块。输入“如何匹配专有名词”BM25 会优先召回包含“专有名词”“BM25 算法”的文本块稠密向量也会同步召回语义相近内容RRF 融合后排序更稳定。输入“北京明天天气怎么样”知识库中没有相关内容LLM 会回答“知识库中未找到相关信息”而不是编造天气。输出示例 检索到的参考文档 [1] rrf_score0.0323 RRF 倒数排名融合算法可以将多路检索结果合并排序。 [2] rrf_score0.0317 混合检索包括稀疏向量检索和稠密向量检索两部分。 [3] rrf_score0.0156 稀疏向量检索使用 BM25 算法适合匹配专有名词和编号。 回答 RRFReciprocal Rank Fusion是一种将多路检索结果合并排序的算法它根据文档在不同检索系统中的排名位置计算融合得分避免不同检索系统的得分尺度不一致问题。8. RAG 效果评估知识库指标到底怎么理解很多开发者在完成 RAG 系统后只会“人工试几个问题”这远远不够。要把系统做扎实必须建立一套可量化的评估体系。RAG 评估通常分为检索质量和生成质量两部分。8.1 检索质量指标检索质量衡量的是“检索器能不能把正确的文档排到前面”。常用指标有指标全称/含义理解方法RecallK前 K 条结果中命中的相关文档比例目标是“真正有用的内容有没有被捞出来”不关心顺序Hit Rate前 K 条结果中是否出现至少一条相关文档常用来快速判断检索是否完全失效MRRMean Reciprocal Rank第一个相关文档排名的倒数平均值关心“第一条相关文档出现在第几位”适合单文档场景NDCGNormalized Discounted Cumulative Gain归一化折损累计增益关心整个排序列表的质量相关文档越靠前得分越高这些指标可以在离线阶段用人工标注的问答测试集计算。比如构造 100 个问题每个问题标注正确的文档 ID然后逐个查询统计 Recall5、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.5768 维查询时换成了bge-large-zh-v1.51024 维。解决办法是删除原有向量库统一模型后重新建库。检索到了内容但 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 等工具完成自动化评估。如果想把这套代码落到你自己的数据集上建议先保留小规模评估集修改检索策略后逐步回归对比。只有建立在量化评估基础上的优化才是真正可持续的优化。
返回列表