Appearance
入门做的 RAG,检索这一步其实很"素":把用户的问题直接转成向量,去库里找最像的几段。测试时好好的,一到真实用户手里就常常"差一点"——明明库里有答案,它就是没找到。这一篇用大白话讲清检索为什么会不准,以及两个立竿见影的改进:查询改写和混合检索。代码以 Python 为主,Node 的对应写法会在关键处标注。
检索为什么会「差一点」
想象你在图书馆找书,但只能对着管理员说一句话,而且管理员只按字面意思帮你找。两个问题就出来了:
一是"说法对不上"。 你库里的文档写的是「陪产假为 15 天」,用户问的是「我媳妇生了,我能歇几天」。意思一模一样,但用词几乎不重合。纯向量检索虽然能抓一部分语义,但遇到专有名词、缩写、口语,还是容易翻车。
二是"一次问不全"。 用户一句话往往表达得不完整或有歧义。只用这一句去检索,就像只撒一次网——角度稍微偏一点,就把真正相关的资料漏掉了。
进阶 RAG 的很多技巧,本质都在解决这两件事:让"说法"能对上、让"网"多撒几次。
改进一:查询改写(一句变多句)
思路特别简单:既然用户一句话容易漏,那就先让大模型把这句话改写成好几种不同的问法,每一种都去检索一遍,最后把结果合并去重。相当于从多个角度一起撒网,召回率立刻上一个台阶。
LangChain 内置了 MultiQueryRetriever 帮你干这件事,你只要把它套在原来的检索器外面:
python
import os
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from langchain_core.vectorstores import InMemoryVectorStore
from langchain_core.documents import Document
from langchain_classic.retrievers.multi_query import MultiQueryRetriever
model = ChatOpenAI(
model="qwen-plus",
api_key=os.getenv("DASHSCOPE_API_KEY"),
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
temperature=0,
)
embeddings = OpenAIEmbeddings(
model="text-embedding-v3",
api_key=os.getenv("DASHSCOPE_API_KEY"),
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
)
docs = [Document(page_content="陪产假:配偶生育,男方可休 15 天。"),
Document(page_content="年假:入职满一年后每年 5 天带薪年假。")]
base_retriever = InMemoryVectorStore.from_documents(docs, embeddings).as_retriever()
# 把原检索器包一层:它会先让 model 把问题改写成多种问法,各检索一遍再合并
retriever = MultiQueryRetriever.from_llm(retriever=base_retriever, llm=model)
results = retriever.invoke("我媳妇生了,我能歇几天")
for d in results:
print(d.page_content)这里发生了什么,具体拆给你看。用户原话是一句大白话,模型在背后会把它"翻译"成几种更贴近文档措辞的问法:
text
用户原话:我媳妇生了,我能歇几天
↓ 模型自动改写成 ↓
① 陪产假有多少天?
② 男方配偶生育可以休假吗?
③ 生育假期的相关规定是什么?然后这三条各自去库里检索一遍,结果合并去重。你的文档里写的是「陪产假:配偶生育,男方可休 15 天」——用户原话一个字都对不上,但改写出来的第 ① 条「陪产假有多少天」几乎和文档一模一样,稳稳命中。这就是"说法对上了"。
打个比方:原来是你一个人按自己的说法去图书馆找书,找不到就算了;现在是你先把需求告诉三个各有经验的同事,让他们分别用自己的说法去找,三个人的结果凑一起,漏掉的概率自然小得多。代价也很实在:一次提问变成了"改写 + 多次检索",会多花一点时间和 token——所以它适合用在"宁可慢一点也要找得全"的场景。
Node 侧:LangChain.js 也有
MultiQueryRetriever,用法一致(MultiQueryRetriever.fromLLM({ retriever, llm }))。本系列进阶篇以 Python 为主,但这些检索器在两边基本对称。
改进二:混合检索(关键词 + 向量,双保险)
向量检索擅长"懂意思",但对精确的关键词(产品型号 X20 Pro、错误码 E45、人名)反而不敏感。而老牌的关键词检索(BM25,就是传统搜索引擎那套按词匹配打分的算法)恰恰相反:它认死字面,精确词命中特别准,但不懂近义。
打个比方:向量检索像一个理解力很强但记性一般的人,你说个大概意思他就懂,但你报一串精确的编号他记不牢;BM25 像一个一字不差、但不懂变通的人,你说的词只要在书里出现过他一定翻得到,可你换个近义词他就懵了。
来看个具体例子。用户问:「X20 Pro 出现 E45 怎么办」,你库里有这么一段:「型号 X20 Pro 报错代码 E45,通常是尘盒未装到位,重新安装即可。」
- 只用向量检索:它能理解"出问题、怎么办"的语义,但
X20 Pro、E45这种没含义的字符串,向量表达很弱,很可能把这段排到后面,甚至漏掉。 - 只用 BM25:
X20 Pro、E45这两个精确词一撞一个准,直接命中;但如果用户问的是「机器坏了亮红灯」这种没提到任何精确词的,它又抓瞎了。
看出来了吗?两者的强项和弱项正好互补。混合检索就是让它俩各查一路,再把两边的结果加权融合——精确词交给 BM25,语义交给向量,谁也别漏。LangChain 用 EnsembleRetriever 一行搞定:
python
from langchain_community.retrievers import BM25Retriever
from langchain_classic.retrievers.ensemble import EnsembleRetriever
# 关键词检索器(BM25,按词面匹配,擅长精确词)
bm25 = BM25Retriever.from_documents(docs)
bm25.k = 3
# 向量检索器(懂语义,擅长近义)
vector_retriever = InMemoryVectorStore.from_documents(docs, embeddings).as_retriever(
search_kwargs={"k": 3}
)
# 混合:两路一起检索,按权重融合排序(这里各占一半)
hybrid = EnsembleRetriever(
retrievers=[bm25, vector_retriever],
weights=[0.5, 0.5],
)
results = hybrid.invoke("X20 Pro 的滤网怎么清洗")
for d in results:
print(d.page_content)X20 Pro 这种精确型号靠 BM25 稳稳命中,「怎么清洗」这种语义靠向量补齐——单用任何一路都可能漏,合起来就稳了。权重可以调:如果你的场景精确词很重要,就把 BM25 的权重调高。
Node 侧:
EnsembleRetriever在 LangChain.js 里同样有;BM25 关键词检索在 Node 生态里选择较少,常见做法是接一个专门的搜索服务(如 Elasticsearch)来充当关键词那一路。
这两招能解决什么、不能解决什么
查询改写和混合检索,主要提升的是召回——让"本该找到的资料别被漏掉"。但它们不解决另一个问题:找回来的一堆资料里,最相关的那条不一定排在最前面。如果你只把前 3 条喂给模型,而正确答案排在第 5 条,照样白搭。
把最相关的精准地顶到最前面,靠的是重排序(rerank)——这是下一篇的主角。连同"文档到底该怎么切""向量库怎么选",我们下一篇《进阶 RAG(下)》一起讲。
小结
这一篇记住两个直觉就够了:用户一句话容易漏 → 让模型改写成多句一起查(查询改写);向量懂语义但对精确词迟钝 → 拉上关键词检索一起上(混合检索)。 它俩都在解决同一件事——把该找到的资料尽量都找回来。
下一篇,我们解决"找回来之后怎么排得准"的问题。