先说结论:这个争论本身就有问题,很多人把“RAG”和“向量检索”绑得太死,仿佛不做向量就不是正经RAG。我在实际项目里试过纯BM25关键词检索、试过知识图谱路径检索、也试过向量+稀疏检索的混合方案,踩了不少坑之后才确定:向量只是RAG检索层的一种召回手段,不是RAG的必要条件。这篇文章把我这几轮折腾的对比结果、选型逻辑和一套零基础可复制的本地混合检索RAG方案完整写出来,适合正在做RAG落地、被召回效果折磨、或者纠结“要不要上向量数据库”的朋友参考。
1. 内容整体设计与思路拆解
1.1 为什么会出现“RAG不需要向量”的说法
过去一年多,RAG相关项目井喷,但真正上线后效果能打的其实不多。很多团队把问题归咎于“向量检索不准”,于是不停换向量数据库、换Embedding模型,结果提升有限。我复盘过自己的几个项目,也看过不少社区里的失败案例,发现一个共性:瓶颈往往不在向量召回本身,而在更前置的环节——文档拆解粒度、查询意图识别、召回后的重排,甚至知识库本身的组织结构。
“RAG不需要向量”这句话,准确讲应该拆成两层意思:
- 第一层:对很多知识库场景,关键词检索的精确匹配能力被严重低估。人名、产品型号、法规条款、代码报错信息这类含精确标识符的query,BM25的命中效果经常超过向量检索。
- 第二层:向量检索即便需要,也不一定需要向量数据库。数据量在百万级以内、单机运行、并发不高的场景,用FAISS之类的库内索引完全够用,没必要引入一套分布式向量数据库增加运维复杂度。
基于这种认知,我在新的RAG项目里不再默认“上向量”,而是根据知识库语义密度和query形态决定检索方案。后面会展开讲我是怎么做技术选型的。
1.2 检索方案选型背后的核心逻辑
做技术选型时,我习惯先问自己三个问题:
- 知识库里被检索的单位是什么?是半页纸的章节目录,还是几万字的合同原件,还是碎片化的FAQ问答对?
- 用户的query长什么样?是“公司年假制度是什么”这种开放描述,还是“A100-P01型设备报警代码E204”这种精确标识符?
- 对召回结果的容忍度是多少?允许返回5条再让LLM挑,还是一定要排第一就准?
这三个问题直接决定了检索层方案。举个例子,我在做一个设备维修知识库时,用户的真实提问大量包含“E204”“报警”“PR-302”这类关键词,用向量召回经常把“PR-301”和“PR-302”的文档混在一起,因为Embedding模型觉得它们语义相近,但工程师要的就是精确区分。后来我在检索层加了一个基于倒排索引的关键词召回通道,把精确标记类query直接走BM25,效果立刻好了不少。
另一个维度是数据规模与更新频率。向量需要离线构建索引,更新一条文档就要重新Embedding;而倒排索引增量更新几乎是实时的。如果知识库每天都在变,纯向量方案的运维成本会明显高出很多。
2. 核心细节解析与实操要点
2.1 向量检索到底在解决什么问题
向量检索的本质,是把文本映射到高维语义空间,用距离度量语义相似度。它解决的是“字面上不同、语义上相关”的匹配问题。比如query是“怎么请假”,文档里写的是“休假申请流程”,字面重叠度很低,但语义高度相关——这种场景就是向量的主场。
这个能力来自Embedding模型,常见的本地可选模型有bge-m3、bge-large-zh-v1.5、text2vec-large-chinese等。用bge-m3跑一条文本,会得到一个1024维的浮点向量。两条文本的语义相似度,通常用余弦相似度计算:
similarity = (A · B) / (||A|| × ||B||)其中 A、B 是两个向量,结果在-1到1之间。实际工程里,向量维度越高,计算量越大;但语义区分度并不一定线性提升,所以选择Embedding模型时不能只看维度高不高。
向量的另一个关键点是需要模型理解上下文,但它对“精确匹配”反而钝感。这解释了为什么上面说的设备报警代码场景会翻车。向量擅长模糊匹配,不擅长精确区分,这是它作为唯一检索通道时的天然短板。
2.2 关键词检索(BM25)为什么被低估
BM25是经典的概率检索模型,公式核心是对query中的每个词项计算加权得分:
score(D, Q) = Σ (IDF(qi) × (f(qi, D) × (k1 + 1)) / (f(qi, D) + k1 × (1 - b + b × |D| / avgdl)))看着复杂,实际干的事情很朴素:词在你的文档里出现得越多,且这个词在整库里越稀有,那这篇文档得分越高。f(qi, D)是词频,IDF逆文档频率衡量词的区分度,k1、b通常取1.2和0.75。
BM25的优势在于可解释性强、构建成本低、精确匹配能力好。设备型号、报错代码、人名、法规编号这类query,命中就是硬命中。劣势在于对同义词无能为力——“怎么请假”匹配不到“休假申请流程”。实际做混合检索,正是让BM25和向量各自补位。
2.3 两个检索通道的融合策略
我现在的默认方案是混合检索(Hybrid Search):BM25通道召回Top20,向量通道召回Top20,融合后统一送给后续重排模块。融合不是简单拼接,而是用RRF(Reciprocal Rank Fusion)做分数融合:
RRFscore(d) = Σ 1 / (k + rank(d))其中k是常数(通常取60)。这个公式的思路是:不看具体分数是多少,只看文档在每个通道里的排名位置。第1名的贡献约1/61,第10名的贡献约1/70,差距不大,这就避免了两个通道分数量纲不一致的问题——BM25的分数可能是十几,向量余弦相似度是0.8,直接加在一起完全没意义,但排名融合天然免疫这个差异。
融合后的文档列表如果直接扔给LLM,仍然可能出现“Top1不是最准”的问题。所以我在融合层后加了一个重排(Rerank)步骤——用一个cross-encoder模型重新给融合结果打分。cross-encoder把query和文档拼接后整体过一遍模型计算相关度得分,效果远好于向量召回时的双塔式余弦相似度。本地可用的模型有bge-reranker-large或bge-reranker-base,体量可控,效果非常明显。
3. 实操过程与核心环节实现
3.1 零基础搭建本地混合检索RAG
我这次用的方案是Ollama + Python + Chroma + BM25,全程本地运行,不需要外部API,也不需要GPU——CPU跑bge-m3和bge-reranker-base都能接受,只是慢一点。
先列一下工具选型的理由:
- Ollama: 本地跑大模型最简单的方式,一条命令下载并启动llama3.1或qwen2.5模型,暴露一个OpenAI兼容的HTTP接口。
- Chroma: 轻量级向量数据库,单机、嵌入式,不需要单独部署服务,数据存在本地目录,适合个人项目和中小团队。也常被用作LangChain、LlamaIndex的默认向量库。
- BM25: 用rank_bm25库实现,几十行代码就能完成倒排索引构建和检索。
- bge-m3: BAAI开源的Embedding模型,支持中文效果好,Ollama直接支持。
- bge-reranker-base: 重排模型,HuggingFace上直接下载权重,本地跑一个推理脚本。
环境准备:
# 安装依赖 pip install chromadb rank-bm25 transformers torch sentencepiece # 通过Ollama拉取Embedding模型和对话模型 ollama pull bge-m3 ollama pull qwen2.5:7b3.2 文档加载与拆解
RAG落地最容易忽略的一步就是文本拆解。直接整篇塞进Embedding模型肯定不行,上下文太长既不经济,召回命中率也差。我试过按固定长度拆(比如500字符切一段),效果一般,因为经常从句子中间切断,语义不完整。
后来换成了父子分块策略:先把文档按Markdown标题结构拆成章节(父块),再把大章节继续拆到300~500字的段落(子块)。检索时命中子块,返回时把所属父块一起返回给LLM作为上下文,这样既保证了召回粒度,又保留了完整语义。
文档结构解析 → 章节父块 → 段落子块 ↓ 子块做Embedding进向量库 子块原文进BM25索引 父块原文留作最终LLM上下文我用的是Python的markdown库配合递归解析,头尾相接的规则可以按需要调整。实操里最常见的一个坑是表格被拆散,拆解后表格一半在上一块一半在下一块,LLM完全看不懂。我的做法是在拆解前把表格单独抽出来作为独立块,并且在拆解边界加“标题、表格完结后再切分”的判断规则。
3.3 构建检索层
构建向量索引:
from chromadb import PersistentClient from chromadb.utils import embedding_functions # 指定本地Ollama的embedding端点 ef = embedding_functions.OllamaEmbeddingFunction( url="http://localhost:11434/api/embed", model_name="bge-m3" ) client = PersistentClient(path="./chroma_data") collection = client.get_or_create_collection( name="knowledge_base", embedding_function=ef, metadata={"hnsw:space": "cosine"} # 使用余弦距离 ) # 批量写入子块 for chunk_id, text in enumerate(sub_chunks): collection.add( ids=[str(chunk_id)], documents=[text], metadatas=[{"parent_id": parent_id}] )构建BM25索引:
from rank_bm25 import BM25Okapi tokenized_chunks = [simple_tokenize(chunk) for chunk in sub_chunks] bm25 = BM25Okapi(tokenized_chunks)关于simple_tokenize,中文场景建议用jieba分词,否则BM25跑在整句上效果很差。我试过直接按字符切,效果生硬,加上分词之后明显好了很多。这里的tokenizer代码大概长这样:
import jieba def simple_tokenize(text): return list(jieba.cut(text))3.4 RRF融合与重排
两个通道拿回结果后,用RRF把排名融起来:
def rrf_fusion(results_list, k=60): scores = {} for results in results_list: for rank, doc_id in enumerate(results): scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + rank + 1) return sorted(scores.items(), key=lambda x: x[1], reverse=True)这步跑完后,接下来是把融合Top10的文本段送去重排。重排模型bge-reranker-base加载后会比较慢,但一次只处理十条query-doc对,CPU也能跑:
from transformers import AutoModelForSequenceClassification, AutoTokenizer reranker_tokenizer = AutoTokenizer.from_pretrained("BAAI/bge-reranker-base") reranker_model = AutoModelForSequenceClassification.from_pretrained("BAAI/bge-reranker-base") reranker_model.eval() def rerank(query, docs): pairs = [[query, doc] for doc in docs] inputs = reranker_tokenizer(pairs, padding=True, truncation=True, max_length=512, return_tensors="pt") with torch.no_grad(): scores = reranker_model(**inputs).logits.view(-1) return scores把重排后的Top5作为上下文拼进Prompt,交给Ollama上的qwen2.5生成最终答案。并在Prompt里强制要求模型“如果上下文不足以回答问题,请直接说不知道,不要编造”。这一步能显著压降LLM幻觉,敢说“不知道”比硬编一个答案好得多。
3.5 完整可参考的Prompt模板
我目前在生产用的系统Prompt是下面这个:
你是一个专业的知识库问答助手。请严格基于以下提供的资料内容回答用户问题。 要求: 1. 如果资料中没有相关信息,请明确回答“资料中未找到相关信息”。 2. 不要编造资料中不存在的事实、数据或结论。 3. 回答时引用资料序号,例如[1]代表第一条资料。 参考资料: [1] {chunk_1} [2] {chunk_2} [3] {chunk_3} 用户问题:{question}这个模板看起来简单,但“不要编造”这句话的约束力在qwen2.5、llama3这类模型上实测效果非常明显。生成答案的temperature我建议调到0.2以下,避免发散。
4. 常见问题与排查技巧实录
4.1 为什么召回结果里总混入无关内容
最典型的原因是分块粒度太大——块越长,向量化后语义越被稀释,整块里只有一小段相关,但整块被当作一个向量参与匹配,噪声就进来了。排查方法很简单:把召回Top10逐条看一遍,如果大量命中块只有局部相关,就把块切小一点再测试。另一个常见原因是Embedding模型本身的同义词扩展带来误召回,比如“年假”匹配出“婚假”文档,这在向量结果里很难完全避免,靠重排层能压掉一部分。
4.2 BM25分词不合适导致关键词失效
前面说过中文场景必须分词。但分词选错也坑,比如设备型号“PR-302”会被jieba切分成“PR-302”和“PR”两个词,BM25按词索引时就尴尬。我查下来最好的做法是:保留一份原始文本的字符级索引,对精确匹配类query用字符n-gram再加一层通道,专门对付代码、型号、序列号这类文本。目前这个逻辑我有两条路线:一是预处理时把连续字母数字组合作为整体token保留;二是对这个通道采用字符级bigram检索。实测这两种都不复杂,但效果提升立竿见影。
4.3 向量数据库选型到底怎么选
回到标题里的热搜词“向量数据库选型”,很多朋友纠结Chroma、FAISS、Milvus、Qdrant、Weaviate选哪个。我的经验是:
- 数据量<100万条、单机够用、不想额外运维→ Chroma(或FAISS直接嵌进代码)。
- 需要过滤大量元数据、按租户隔离、并发较高→ Qdrant或Milvus。
- 已经重度使用Elasticsearch→ 直接用ES的dense_vector字段,少一套组件,让BM25和向量在同一个引擎内融合。
选型的本质是在迁移和维护语言上做减法:尽可能复用你团队已有的基础设施。很多项目为了“上向量数据库”而上,多了一套分布式集群要维护,出了问题定位链路也漫长,这是完全不必要的负担。我在本地项目和中小团队咨询里一贯推荐从嵌入式方案起步,跑出了效果再考虑独立部署。
4.4 本地检索RAG的效果评测
评估RAG效果不能凭感觉,我给自己定了一套最低限度的评测指标,每次换检索方案、换模型后都会跑一遍:
| 指标 | 含义 | 我的做法 |
|---|---|---|
| Recall@5 | 正确答案是否排在Top5内 | 人工标注50条测试query,看答案是否出现在融合后前5条 |
| MRR | 正确答案排名的倒数 | 看排名的“靠前程度”,排第1得1分,排第2得0.5 |
| 幻觉率 | LLM回答中编造内容占比 | 逐条检查回答中的事实性表述是否可溯源到资料 |
| 响应时间 | 端到端耗时 | 从用户提问到答案输出,记录P95耗时 |
评测集不要求大,但必须有代表性。我会把知识库里最难找的30~50个问题单独拎出来做“困难集”,每次改动后先看困难集有没有变好,再评估整体指标。
4.5 一个稳定盈利项目的复盘清单
这里额外写一段个人体会。我目前跑得最稳的一个知识库问答项目,是给一个中小型企业的内部规章制度与历史项目材料做问答。这个项目从一开始就没有上向量数据库,检索层用的就是Elasticsearch的BM25加同义词词典扩展,重排用的是bge-reranker,再配一条窄向量通道做长尾召回,加起来一个周末就能部署完整,效果客户认可,成本几乎可以忽略。
我的体会是:架构复杂不等于效果优秀,业务问题所需要的检索语义深度,远低于从业者倾向于配置的技术栈深度。RAG项目真正吃时间的地方在于对文档结构的理解和对业务query的归纳,而不是在数据库选型和Embedding模型调参上反复横跳。无论读者最终选择纯BM25、纯向量,还是混合方案,有一个简单的判断标准值得时刻记住:每周抽出半天,拿真实业务问题去跑一遍评测集,比任何架构讨论都更有说服力。