不依赖框架,从 numpy 手算余弦相似度开始,逐步引入向量数据库、混合检索、rerank,
全程在真实语料(爬取的阿里云百炼官方文档,5.2 万字)上做实验。
写作思路参考了经典的 "Naive RAG → Advanced RAG" 问题驱动式结构:
每一步都先暴露问题,再给解法,最后给出生产环境的成熟替代方案。
一、RAG 是什么
大模型有两个致命短板:知识截止 (不知道最新信息)和幻觉 (不懂就编)。
RAG(Retrieval-Augmented Generation,检索增强生成)的思路很朴素:
答题之前先查资料------从自己的知识库里检索出相关内容,塞进 prompt,让模型"开卷考试"。
核心流程五步:
#mermaid-svg-sKtDqyPlILtCbeI3{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-sKtDqyPlILtCbeI3 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-sKtDqyPlILtCbeI3 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-sKtDqyPlILtCbeI3 .error-icon{fill:#552222;}#mermaid-svg-sKtDqyPlILtCbeI3 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-sKtDqyPlILtCbeI3 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-sKtDqyPlILtCbeI3 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-sKtDqyPlILtCbeI3 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-sKtDqyPlILtCbeI3 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-sKtDqyPlILtCbeI3 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-sKtDqyPlILtCbeI3 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-sKtDqyPlILtCbeI3 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-sKtDqyPlILtCbeI3 .marker.cross{stroke:#333333;}#mermaid-svg-sKtDqyPlILtCbeI3 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-sKtDqyPlILtCbeI3 p{margin:0;}#mermaid-svg-sKtDqyPlILtCbeI3 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-sKtDqyPlILtCbeI3 .cluster-label text{fill:#333;}#mermaid-svg-sKtDqyPlILtCbeI3 .cluster-label span{color:#333;}#mermaid-svg-sKtDqyPlILtCbeI3 .cluster-label span p{background-color:transparent;}#mermaid-svg-sKtDqyPlILtCbeI3 .label text,#mermaid-svg-sKtDqyPlILtCbeI3 span{fill:#333;color:#333;}#mermaid-svg-sKtDqyPlILtCbeI3 .node rect,#mermaid-svg-sKtDqyPlILtCbeI3 .node circle,#mermaid-svg-sKtDqyPlILtCbeI3 .node ellipse,#mermaid-svg-sKtDqyPlILtCbeI3 .node polygon,#mermaid-svg-sKtDqyPlILtCbeI3 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-sKtDqyPlILtCbeI3 .rough-node .label text,#mermaid-svg-sKtDqyPlILtCbeI3 .node .label text,#mermaid-svg-sKtDqyPlILtCbeI3 .image-shape .label,#mermaid-svg-sKtDqyPlILtCbeI3 .icon-shape .label{text-anchor:middle;}#mermaid-svg-sKtDqyPlILtCbeI3 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-sKtDqyPlILtCbeI3 .rough-node .label,#mermaid-svg-sKtDqyPlILtCbeI3 .node .label,#mermaid-svg-sKtDqyPlILtCbeI3 .image-shape .label,#mermaid-svg-sKtDqyPlILtCbeI3 .icon-shape .label{text-align:center;}#mermaid-svg-sKtDqyPlILtCbeI3 .node.clickable{cursor:pointer;}#mermaid-svg-sKtDqyPlILtCbeI3 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-sKtDqyPlILtCbeI3 .arrowheadPath{fill:#333333;}#mermaid-svg-sKtDqyPlILtCbeI3 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-sKtDqyPlILtCbeI3 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-sKtDqyPlILtCbeI3 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-sKtDqyPlILtCbeI3 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-sKtDqyPlILtCbeI3 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-sKtDqyPlILtCbeI3 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-sKtDqyPlILtCbeI3 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-sKtDqyPlILtCbeI3 .cluster text{fill:#333;}#mermaid-svg-sKtDqyPlILtCbeI3 .cluster span{color:#333;}#mermaid-svg-sKtDqyPlILtCbeI3 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-sKtDqyPlILtCbeI3 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-sKtDqyPlILtCbeI3 rect.text{fill:none;stroke-width:0;}#mermaid-svg-sKtDqyPlILtCbeI3 .icon-shape,#mermaid-svg-sKtDqyPlILtCbeI3 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-sKtDqyPlILtCbeI3 .icon-shape p,#mermaid-svg-sKtDqyPlILtCbeI3 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-sKtDqyPlILtCbeI3 .icon-shape .label rect,#mermaid-svg-sKtDqyPlILtCbeI3 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-sKtDqyPlILtCbeI3 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-sKtDqyPlILtCbeI3 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-sKtDqyPlILtCbeI3 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 📄 文档
✂️ 切分 Chunk
🔢 向量化 Embedding
🗄️ 向量数据库
❓ 用户问题
🔢 向量化
🔍 相似度检索
📝 拼接 Prompt
🤖 LLM 生成回答
对应到系统时序,分离线建索引 和在线问答两个阶段:
LLM 向量数据库 Embedding模型 RAG服务 用户 LLM 向量数据库 Embedding模型 RAG服务 用户 #mermaid-svg-i2MVkcnNlYLsC8Lf{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-i2MVkcnNlYLsC8Lf .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-i2MVkcnNlYLsC8Lf .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-i2MVkcnNlYLsC8Lf .error-icon{fill:#552222;}#mermaid-svg-i2MVkcnNlYLsC8Lf .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-i2MVkcnNlYLsC8Lf .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-i2MVkcnNlYLsC8Lf .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-i2MVkcnNlYLsC8Lf .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-i2MVkcnNlYLsC8Lf .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-i2MVkcnNlYLsC8Lf .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-i2MVkcnNlYLsC8Lf .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-i2MVkcnNlYLsC8Lf .marker{fill:#333333;stroke:#333333;}#mermaid-svg-i2MVkcnNlYLsC8Lf .marker.cross{stroke:#333333;}#mermaid-svg-i2MVkcnNlYLsC8Lf svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-i2MVkcnNlYLsC8Lf p{margin:0;}#mermaid-svg-i2MVkcnNlYLsC8Lf .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-i2MVkcnNlYLsC8Lf text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-i2MVkcnNlYLsC8Lf .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-i2MVkcnNlYLsC8Lf .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-i2MVkcnNlYLsC8Lf .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-i2MVkcnNlYLsC8Lf .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-i2MVkcnNlYLsC8Lf #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-i2MVkcnNlYLsC8Lf .sequenceNumber{fill:white;}#mermaid-svg-i2MVkcnNlYLsC8Lf #sequencenumber{fill:#333;}#mermaid-svg-i2MVkcnNlYLsC8Lf #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-i2MVkcnNlYLsC8Lf .messageText{fill:#333;stroke:none;}#mermaid-svg-i2MVkcnNlYLsC8Lf .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-i2MVkcnNlYLsC8Lf .labelText,#mermaid-svg-i2MVkcnNlYLsC8Lf .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-i2MVkcnNlYLsC8Lf .loopText,#mermaid-svg-i2MVkcnNlYLsC8Lf .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-i2MVkcnNlYLsC8Lf .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-i2MVkcnNlYLsC8Lf .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-i2MVkcnNlYLsC8Lf .noteText,#mermaid-svg-i2MVkcnNlYLsC8Lf .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-i2MVkcnNlYLsC8Lf .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-i2MVkcnNlYLsC8Lf .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-i2MVkcnNlYLsC8Lf .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-i2MVkcnNlYLsC8Lf .actorPopupMenu{position:absolute;}#mermaid-svg-i2MVkcnNlYLsC8Lf .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-i2MVkcnNlYLsC8Lf .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-i2MVkcnNlYLsC8Lf .actor-man circle,#mermaid-svg-i2MVkcnNlYLsC8Lf line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-i2MVkcnNlYLsC8Lf :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 离线阶段:建索引(只做一次) 在线阶段:每次问答 加载文档 + 切分1批量向量化所有文本块2向量数组 (n, 1024)3写入向量 + 原文 + 元数据4提问:"出差住宿报销标准?"5向量化问题6问题向量 (1024,)7ANN 相似度检索 Top-K8相关文本块9Prompt = 检索结果 + 问题10基于资料的回答11
二、最小 RAG:100 行代码手搓(不用任何框架)
为了理解原理,第一版 demo 不用向量数据库,直接用 numpy 手算。核心在检索函数:
python
def retrieve(query, chunks, chunk_vectors, top_k=2):
query_vector = embed([query])[0]
# 余弦相似度:A·B / (|A|×|B|),消除向量长度影响,只看方向
scores = chunk_vectors @ query_vector / (
np.linalg.norm(chunk_vectors, axis=1) * np.linalg.norm(query_vector)
)
# argsort 返回"按值排序后的下标",[::-1] 反转成降序,取前 K 个
top_indices = np.argsort(scores)[::-1][:top_k]
return [(chunks[i], float(scores[i])) for i in top_indices]
几个关键点:
- 余弦相似度 :
@是矩阵乘法(点积),除以模长做归一化。结果 ∈ -1, 1,越大越相似。
类比 Java:@相当于对每个 chunk 循环算sum(a[i]*b[i])。 - argsort 返回的是下标不是值 :拿到下标才能回
chunks里取原文。 - 实际 embedding 模型产出的相似度普遍在 0.4~0.8 之间,工程上常设阈值过滤低质量召回。
踩坑:检索准 ≠ 回答对
用免费的 7B 小模型做生成时遇到了典型幻觉:
问题:出差住宿报销标准是什么?(正确答案:500/400/300)
7B 模型回答:一线城市 550 元/晚,二线城市 400 元 ❌ 数字被编了
检索明明命中了正确片段(相似度 0.67),小模型却篡改了数字。
temperature=0 + prompt 强调"原样引用数字"只能缓解,模型能力有上限 。
换用 DeepSeek-V3 / qwen-turbo 后立刻全对。RAG 系统的瓶颈经常在生成端,而不是检索端。
三、切分策略:被低估的效果分水岭
实验 setup
用 requests + BeautifulSoup 爬了 3 篇阿里云百炼官方文档(约 5.2 万字)作为真实语料。
数据清洗做了演示级三步:删 script/style/nav/footer → CSS 选择器定位正文容器 → 规范化空白。
(生产环境用 trafilatura / newspaper3k 自动提取正文;维基百科批量语料用 Wikipedia-API 包或官方 dump + wikiextractor。)
四种策略切出来的块,肉眼可见的差距
对同一语料应用 4 种切分策略。先看统计:
| 策略 | 块数 | 平均 | 最小 | 最大 | 特点 |
|---|---|---|---|---|---|
| fixed | 176 | 297 | 71 | 300 | 大小均匀,但从句子中间砍断 |
| overlap | 211 | 297 | 21 | 300 | 重叠保边界,代价是冗余 |
| recursive | 140 | 373 | 12 | 500 | 按语义边界切,大小均匀 |
| heading | 136 | 384 | 12 | 1355 | 语义最完整,块大小不可控 |
统计数字还不直观,直接看同一段内容在不同策略下切出来的块------问题一目了然:
fixed(定长 300 字):块的开头是被拦腰砍断的半句话,代码示例支离破碎:
==================== 块 what-is-model-studio.txt-9(300 字)====================
ns ← 上一个词被切剩下的半个词
,请将{WorkspaceId}替换为业务空间
ID
← 从这里才开始是完整内容,但上下文已经丢了
curl -X POST https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/...
-H "Authorization: Bearer $DASHSCOPE_API_KEY" \
-d '{
"model": "qwen3.8-max",
{ ← JSON 切到一半,块又结束了
recursive(递归切分):同一个 curl 示例被完整保留在一个块里,边界干净:
==================== 块 what-is-model-studio.txt-8(483 字)====================
curl -X POST https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/compatible-mode/v1/chat/completions \
-H "Authorization: Bearer $DASHSCOPE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "qwen3.8-max",
"messages": [
{
"role": "user",
"content": "你是谁?"
}
]
heading(按标题) :语义最完整,但如果不加限制,有的章节一块就有 25,365 字符------
直接超过 embedding 模型的输入上限(8192)触发报错 Range of input length should be [1, 8192]。
检索效果也印证了差距(问题:「调用 API 需要什么认证方式?」):
| 策略 | 距离 | Top-1 内容 | 评价 |
|---|---|---|---|
| fixed | 0.6905 | 文档开头介绍段 | ❌ 无关 |
| overlap | 0.6621 | 教程导语 | ⚠️ 沾边 |
| recursive | 0.7185 | Authorization: Bearer 所在块 |
✅ 正中答案 |
| heading | 0.7185 | 同上 | ✅ 正中答案 |
递归切分是怎么实现的
核心思想一句话:优先在"大"的语义边界(段落)下刀,块还是太长就降级到"小"边界(句子),万不得已才硬切:
python
def chunk_recursive(text, max_size=500, separators=("\n\n", "\n", "。")):
if len(text) <= max_size:
return [text]
if not separators: # 分隔符用完还超长:硬切
return [text[i:i+max_size] for i in range(0, len(text), max_size)]
sep = separators[0]
if sep not in text: # 当前分隔符切不动,直接降级
return chunk_recursive(text, max_size, separators[1:])
parts = text.split(sep)
chunks, current = [], ""
for i, part in enumerate(parts):
piece = part + (sep if i < len(parts) - 1 else "")
if len(piece) > max_size: # 单块仍超长:用【剩余】分隔符递归降级
chunks.extend(chunk_recursive(piece, max_size, separators[1:]))
elif len(current) + len(piece) <= max_size:
current += piece # 能装下就合并,尽量凑满 max_size
else:
chunks.append(current)
current = piece
...
踩坑:自己实现时的两个教训
自己手写递归切分时踩了个经典 bug:切分时把分隔符补回了块尾(piece = part + sep),
超长块带着尾部 \n\n 进入下一轮递归,又被同一个分隔符切出一模一样的自己 → RecursionError。
正确做法:递归时必须传入剩余的分隔符列表,当前分隔符不再复用。
另外纯按标题切分必须配超长兜底(按结构切 + 超长块递归细分的混合策略),否则一个巨块直接把 embedding 接口打爆。
生产环境用什么
上面的代码只是为了理解原理,生产环境不要手写 ,直接用现成的:
LangChain 的 RecursiveCharacterTextSplitter 就是这套思路的工业级实现,也是事实标准:
python
from langchain_text_splitters import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=500,
chunk_overlap=50,
separators=["\n\n", "\n", "。", ",", " "], # 中文场景记得加中文标点
)
chunks = splitter.split_text(document)
LlamaIndex 的 SentenceSplitter、语义切分(按 embedding 相似度拐点切)等也是可选方案。
结论:默认选递归切分(500 字上下 + 少量重叠),判断切分好坏最朴素的标准是------这个块单独给 LLM 看,它能看懂吗?
四、向量数据库:从玩具到可用的分水岭
为什么 numpy 手算是"暴力全扫描"
回看第二节的检索代码:
python
scores = chunk_vectors @ query_vector / (...)
这行代码对每一个 块向量都和问题向量做一次点积------100 个块算 100 次,100 万个块就实打实算 100 万次。它没有任何索引结构,相当于 Java 里用 for 循环遍历 ArrayList 找最大值,而向量数据库干的事相当于给你建了个 HashMap/B+Tree。
算笔账:10 万个块 × 1024 维,每次查询要做 10 万 × 1024 ≈ 1 亿次浮点运算 ,而且这是每次查询都要付的代价。数据量翻倍,耗时翻倍,严格 O(N)。
向量数据库用 ANN(近似最近邻)索引解决这个问题:以 HNSW 为例,建索引时把向量组织成一张多层图,查询时从图的入口层开始贪心跳转,只访问极少数节点就能找到近邻,复杂度降到约 O(log N)------10 万数据从 1 亿次运算降到几百次,毫秒级返回。代价是"近似":不保证 100% 找到理论上的最近邻,但工程上 99% 的召回率完全够用。
#mermaid-svg-kGp02I3TD5Uo5VCy{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-kGp02I3TD5Uo5VCy .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-kGp02I3TD5Uo5VCy .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-kGp02I3TD5Uo5VCy .error-icon{fill:#552222;}#mermaid-svg-kGp02I3TD5Uo5VCy .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-kGp02I3TD5Uo5VCy .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-kGp02I3TD5Uo5VCy .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-kGp02I3TD5Uo5VCy .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-kGp02I3TD5Uo5VCy .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-kGp02I3TD5Uo5VCy .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-kGp02I3TD5Uo5VCy .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-kGp02I3TD5Uo5VCy .marker{fill:#333333;stroke:#333333;}#mermaid-svg-kGp02I3TD5Uo5VCy .marker.cross{stroke:#333333;}#mermaid-svg-kGp02I3TD5Uo5VCy svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-kGp02I3TD5Uo5VCy p{margin:0;}#mermaid-svg-kGp02I3TD5Uo5VCy .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-kGp02I3TD5Uo5VCy .cluster-label text{fill:#333;}#mermaid-svg-kGp02I3TD5Uo5VCy .cluster-label span{color:#333;}#mermaid-svg-kGp02I3TD5Uo5VCy .cluster-label span p{background-color:transparent;}#mermaid-svg-kGp02I3TD5Uo5VCy .label text,#mermaid-svg-kGp02I3TD5Uo5VCy span{fill:#333;color:#333;}#mermaid-svg-kGp02I3TD5Uo5VCy .node rect,#mermaid-svg-kGp02I3TD5Uo5VCy .node circle,#mermaid-svg-kGp02I3TD5Uo5VCy .node ellipse,#mermaid-svg-kGp02I3TD5Uo5VCy .node polygon,#mermaid-svg-kGp02I3TD5Uo5VCy .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-kGp02I3TD5Uo5VCy .rough-node .label text,#mermaid-svg-kGp02I3TD5Uo5VCy .node .label text,#mermaid-svg-kGp02I3TD5Uo5VCy .image-shape .label,#mermaid-svg-kGp02I3TD5Uo5VCy .icon-shape .label{text-anchor:middle;}#mermaid-svg-kGp02I3TD5Uo5VCy .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-kGp02I3TD5Uo5VCy .rough-node .label,#mermaid-svg-kGp02I3TD5Uo5VCy .node .label,#mermaid-svg-kGp02I3TD5Uo5VCy .image-shape .label,#mermaid-svg-kGp02I3TD5Uo5VCy .icon-shape .label{text-align:center;}#mermaid-svg-kGp02I3TD5Uo5VCy .node.clickable{cursor:pointer;}#mermaid-svg-kGp02I3TD5Uo5VCy .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-kGp02I3TD5Uo5VCy .arrowheadPath{fill:#333333;}#mermaid-svg-kGp02I3TD5Uo5VCy .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-kGp02I3TD5Uo5VCy .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-kGp02I3TD5Uo5VCy .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-kGp02I3TD5Uo5VCy .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-kGp02I3TD5Uo5VCy .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-kGp02I3TD5Uo5VCy .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-kGp02I3TD5Uo5VCy .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-kGp02I3TD5Uo5VCy .cluster text{fill:#333;}#mermaid-svg-kGp02I3TD5Uo5VCy .cluster span{color:#333;}#mermaid-svg-kGp02I3TD5Uo5VCy div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-kGp02I3TD5Uo5VCy .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-kGp02I3TD5Uo5VCy rect.text{fill:none;stroke-width:0;}#mermaid-svg-kGp02I3TD5Uo5VCy .icon-shape,#mermaid-svg-kGp02I3TD5Uo5VCy .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-kGp02I3TD5Uo5VCy .icon-shape p,#mermaid-svg-kGp02I3TD5Uo5VCy .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-kGp02I3TD5Uo5VCy .icon-shape .label rect,#mermaid-svg-kGp02I3TD5Uo5VCy .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-kGp02I3TD5Uo5VCy .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-kGp02I3TD5Uo5VCy .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-kGp02I3TD5Uo5VCy :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} HNSW 索引 O(logN)
问题向量
图入口层
跳转到邻近节点
再跳转
命中最近邻
numpy 暴力扫描 O(N)
问题向量
和第1块算
和第2块算
...
和第100000块算
ChromaDB 带来的三个核心能力
| 能力 | numpy 手算 | ChromaDB |
|---|---|---|
| 存储 | 内存数组,进程结束就没了 | PersistentClient 落盘,重启复用 |
| 检索 | 全量扫描 O(N) | HNSW 索引,十万级毫秒返回 |
| 过滤 | 无 | where={"section": "..."},类似 SQL WHERE |
| 向量管理 | 自己维护向量数组 | 托管,query 直接传文本 |
python
db = chromadb.PersistentClient(path="./chroma_db") # 持久化到本地目录
collection = db.get_or_create_collection(
name="company_faq",
embedding_function=DashScopeEmbedding(), # 把"文本→向量"托管给 Chroma
)
collection.upsert(ids=..., documents=..., metadatas=...)
# 检索直接传文本,支持元数据过滤
collection.query(query_texts=["报销标准"], n_results=2,
where={"section": "报销制度"})
一个容易混淆的细节:Chroma 返回的是"距离"(越小越相似),和余弦"相似度"(越大越相似)方向相反 。
Chroma 适合学习和小项目,生产上数据量大可换 Milvus / Qdrant / pgvector。
五、检索优化:工业界标准四件套
为什么向量检索不够:先看两个实测案例
案例 1(关键词型问题) :「API Key 怎么获取?」
向量检索的 Top-1 是 models.txt 里带"API Key"字样的表格碎片 ------词匹配上了,
但那块讲的是 Base URL,不是获取方法。向量检索理解语义,但对"必须精确出现的词"不敏感。
案例 2(语义型问题) :「我想把大模型对接到我自己的系统里」
全文没有 "对接"这个词。向量检索照样命中 Java 调用示例代码块(它理解"对接 ≈ 调用 API");
而纯关键词检索只能捞到含"API/调用"字样的导航段,质量明显差。
两个案例说明:向量管语义,关键词管精确匹配,谁也替代不了谁。
BM25 详解:它从哪里找、怎么找
BM25 检索的就是切出来的那些块 ------和向量检索面对的是同一批 140 个块,
只是换了种"索引"和"打分"方式。它分为离线和在线两步:
离线 :用 jieba 把每个块切成词,建倒排索引 (词 → 包含它的块列表),
本质就是 Elasticsearch 倒排索引那一套:
块A "获取 API Key 请前往控制台" --jieba--> [获取, API, Key, 请, 前往, 控制台]
块B "删除 API Key 的入口" --jieba--> [删除, API, Key, 的, 入口]
倒排索引:
"API" -> [块A, 块B]
"Key" -> [块A, 块B]
"获取" -> [块A]
"控制台" -> [块A]
在线:问题同样分词,拿每个词去倒排索引里查,命中了哪些块就给这些块累计打分:
问题 "API Key 怎么获取" --jieba--> [API, Key, 怎么, 获取]
"API" 命中 块A、块B
"Key" 命中 块A、块B
"获取" 命中 块A
=> 块A 命中 3 个词,块B 命中 2 个词,块A 排名靠前
打分用的 BM25 公式可以直观理解为升级版 TF-IDF,三个核心思想:
- 词越稀有,权重越高(IDF):"API Key"这种词比"的"、"请"值钱得多;
- 词频收益递减(TF 饱和):一个词在块里出现 10 次并不比出现 3 次强多少;
- 块越长越吃亏(长度归一化):长块天然词多,要压一压,防止长块霸榜。
为什么它能补充向量检索? 还是案例 1:向量检索把 "API Key" 当成普通语义处理了,
而 BM25 眼里 "API"、"Key"、"获取" 是必须命中的精确词,
包含全部三个词的块(获取教程)会被词频和稀有度双重加权顶到前面。
反过来案例 2 里 BM25 对着"对接"这个词干瞪眼------语料里根本没有,倒排索引查无此词,
而向量检索靠语义照样能找到。这就是混合检索的意义:语义的归向量,字面的归 BM25。
RRF:把两路结果揉成一路
两路检索各返回一个排名,怎么合并?不能直接加原始分数------
向量距离(0.3~0.9)和 BM25 分数(几分到几十分)量纲完全不同 ,直接加权没有意义。
RRF(Reciprocal Rank Fusion,倒数排名融合)的精妙之处是只用排名:
python
def rrf_fuse(rankings, k=60):
"""块在某一路排名第 r,得分 1/(k+r);多路得分相加。
排名靠前得分高;k=60 是经验值,抹平头部分差,防止单一路霸榜。"""
scores = {}
for ranking in rankings:
for rank, doc_id in enumerate(ranking):
scores[doc_id] = scores.get(doc_id, 0) + 1 / (k + rank + 1)
return sorted(scores, key=scores.get, reverse=True)
举例:块A 在向量路排第 1、BM25 路没进榜;块B 在向量路排第 3、BM25 路排第 1------
块B 两路通吃,总分反超块A。两路都认可的好块,才是真正的优质候选。
rerank:最后的精排
混合检索解决的是"召回"(别把相关块漏掉),但 Top-5 里的顺序还很粗糙。
rerank 用更强的模型 对候选块逐个精审,回答"这个块是否真的能回答这个问题"。
这是本次实验的 LLM 版 rerank 代码(学习用,零依赖):
python
def llm_rerank(query, candidate_ids, id_to_doc):
candidates_text = "\n\n".join(
f"[{i}] {id_to_doc[d][:300]}" for i, d in enumerate(candidate_ids)
)
prompt = f"""请判断下列每个文档片段与问题的相关程度,打分 0-10。
只输出 JSON 数组,如 [8, 3, 0, 9, 5],顺序与片段编号一致。
【问题】{query}
{candidates_text}"""
resp = client.chat.completions.create(
model="qwen-turbo",
messages=[{"role": "user", "content": prompt}],
temperature=0,
)
scores = json.loads(re.search(r"\[[\d.,\s]+\]", resp.choices[0].message.content).group())
return [d for d, _ in sorted(zip(candidate_ids, scores), key=lambda x: x[1], reverse=True)]
实测效果(问题:「API Key 怎么获取?」):rerank 前 Top-1 是 models.txt 的表格碎片,
rerank 后真正讲获取方法的教程块被提到了第 1 位。
为什么不用 LLM 直接检索全部块? 成本。rerank 模型(或 LLM)打分是逐块推理,
140 块全打一遍又慢又贵。所以标准链路是:
廉价方法(向量+BM25)粗召回几十上百个 → 昂贵方法(rerank)只精排小集合。
生产环境不用 LLM 逐块打分,用专门的 cross-encoder rerank 模型,快 10 倍以上:
python
# 阿里云百炼 gte-rerank(DashScope SDK)
import dashscope
resp = dashscope.TextReRank.call(
model="gte-rerank-v2",
query=query,
documents=candidate_docs, # 候选块原文列表
top_n=5,
)
reranked = resp.output.results # 已按相关度排好序
# 开源替代:BAAI/bge-reranker-v2-m3(硅基流动等平台有托管,也可本地部署)
完整链路
#mermaid-svg-nmrjBffvfaRA3eqg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-nmrjBffvfaRA3eqg .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-nmrjBffvfaRA3eqg .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-nmrjBffvfaRA3eqg .error-icon{fill:#552222;}#mermaid-svg-nmrjBffvfaRA3eqg .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-nmrjBffvfaRA3eqg .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-nmrjBffvfaRA3eqg .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-nmrjBffvfaRA3eqg .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-nmrjBffvfaRA3eqg .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-nmrjBffvfaRA3eqg .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-nmrjBffvfaRA3eqg .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-nmrjBffvfaRA3eqg .marker{fill:#333333;stroke:#333333;}#mermaid-svg-nmrjBffvfaRA3eqg .marker.cross{stroke:#333333;}#mermaid-svg-nmrjBffvfaRA3eqg svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-nmrjBffvfaRA3eqg p{margin:0;}#mermaid-svg-nmrjBffvfaRA3eqg .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-nmrjBffvfaRA3eqg .cluster-label text{fill:#333;}#mermaid-svg-nmrjBffvfaRA3eqg .cluster-label span{color:#333;}#mermaid-svg-nmrjBffvfaRA3eqg .cluster-label span p{background-color:transparent;}#mermaid-svg-nmrjBffvfaRA3eqg .label text,#mermaid-svg-nmrjBffvfaRA3eqg span{fill:#333;color:#333;}#mermaid-svg-nmrjBffvfaRA3eqg .node rect,#mermaid-svg-nmrjBffvfaRA3eqg .node circle,#mermaid-svg-nmrjBffvfaRA3eqg .node ellipse,#mermaid-svg-nmrjBffvfaRA3eqg .node polygon,#mermaid-svg-nmrjBffvfaRA3eqg .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-nmrjBffvfaRA3eqg .rough-node .label text,#mermaid-svg-nmrjBffvfaRA3eqg .node .label text,#mermaid-svg-nmrjBffvfaRA3eqg .image-shape .label,#mermaid-svg-nmrjBffvfaRA3eqg .icon-shape .label{text-anchor:middle;}#mermaid-svg-nmrjBffvfaRA3eqg .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-nmrjBffvfaRA3eqg .rough-node .label,#mermaid-svg-nmrjBffvfaRA3eqg .node .label,#mermaid-svg-nmrjBffvfaRA3eqg .image-shape .label,#mermaid-svg-nmrjBffvfaRA3eqg .icon-shape .label{text-align:center;}#mermaid-svg-nmrjBffvfaRA3eqg .node.clickable{cursor:pointer;}#mermaid-svg-nmrjBffvfaRA3eqg .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-nmrjBffvfaRA3eqg .arrowheadPath{fill:#333333;}#mermaid-svg-nmrjBffvfaRA3eqg .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-nmrjBffvfaRA3eqg .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-nmrjBffvfaRA3eqg .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-nmrjBffvfaRA3eqg .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-nmrjBffvfaRA3eqg .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-nmrjBffvfaRA3eqg .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-nmrjBffvfaRA3eqg .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-nmrjBffvfaRA3eqg .cluster text{fill:#333;}#mermaid-svg-nmrjBffvfaRA3eqg .cluster span{color:#333;}#mermaid-svg-nmrjBffvfaRA3eqg div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-nmrjBffvfaRA3eqg .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-nmrjBffvfaRA3eqg rect.text{fill:none;stroke-width:0;}#mermaid-svg-nmrjBffvfaRA3eqg .icon-shape,#mermaid-svg-nmrjBffvfaRA3eqg .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-nmrjBffvfaRA3eqg .icon-shape p,#mermaid-svg-nmrjBffvfaRA3eqg .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-nmrjBffvfaRA3eqg .icon-shape .label rect,#mermaid-svg-nmrjBffvfaRA3eqg .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-nmrjBffvfaRA3eqg .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-nmrjBffvfaRA3eqg .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-nmrjBffvfaRA3eqg :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} ❓ 用户问题
🔢 向量检索
语义相似,召回 Top10
🔤 BM25 检索
倒排索引+词频打分,召回 Top10
⚖️ RRF 融合
按排名加权: 1/60+rank
🤖 rerank 精排
gte-rerank 逐个打分
📦 Top-K 交给 LLM 生成
四种方式分工总结:
| 方式 | 擅长 | 短板 | 角色 |
|---|---|---|---|
| 向量检索 | 语义理解 | 精确词、编号、错误码 | 粗召回 |
| BM25 | 精确词匹配 | 不懂语义 | 粗召回 |
| RRF | 两路互补,量纲无关 | 候选质量仍粗糙 | 融合 |
| rerank | 精细判断"是否答了问题" | 慢、贵 | 精排 |
六、整体架构回顾
#mermaid-svg-7ciUsNjS6HSmOsFQ{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-7ciUsNjS6HSmOsFQ .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-7ciUsNjS6HSmOsFQ .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-7ciUsNjS6HSmOsFQ .error-icon{fill:#552222;}#mermaid-svg-7ciUsNjS6HSmOsFQ .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-7ciUsNjS6HSmOsFQ .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-7ciUsNjS6HSmOsFQ .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-7ciUsNjS6HSmOsFQ .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-7ciUsNjS6HSmOsFQ .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-7ciUsNjS6HSmOsFQ .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-7ciUsNjS6HSmOsFQ .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-7ciUsNjS6HSmOsFQ .marker{fill:#333333;stroke:#333333;}#mermaid-svg-7ciUsNjS6HSmOsFQ .marker.cross{stroke:#333333;}#mermaid-svg-7ciUsNjS6HSmOsFQ svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-7ciUsNjS6HSmOsFQ p{margin:0;}#mermaid-svg-7ciUsNjS6HSmOsFQ .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-7ciUsNjS6HSmOsFQ .cluster-label text{fill:#333;}#mermaid-svg-7ciUsNjS6HSmOsFQ .cluster-label span{color:#333;}#mermaid-svg-7ciUsNjS6HSmOsFQ .cluster-label span p{background-color:transparent;}#mermaid-svg-7ciUsNjS6HSmOsFQ .label text,#mermaid-svg-7ciUsNjS6HSmOsFQ span{fill:#333;color:#333;}#mermaid-svg-7ciUsNjS6HSmOsFQ .node rect,#mermaid-svg-7ciUsNjS6HSmOsFQ .node circle,#mermaid-svg-7ciUsNjS6HSmOsFQ .node ellipse,#mermaid-svg-7ciUsNjS6HSmOsFQ .node polygon,#mermaid-svg-7ciUsNjS6HSmOsFQ .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-7ciUsNjS6HSmOsFQ .rough-node .label text,#mermaid-svg-7ciUsNjS6HSmOsFQ .node .label text,#mermaid-svg-7ciUsNjS6HSmOsFQ .image-shape .label,#mermaid-svg-7ciUsNjS6HSmOsFQ .icon-shape .label{text-anchor:middle;}#mermaid-svg-7ciUsNjS6HSmOsFQ .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-7ciUsNjS6HSmOsFQ .rough-node .label,#mermaid-svg-7ciUsNjS6HSmOsFQ .node .label,#mermaid-svg-7ciUsNjS6HSmOsFQ .image-shape .label,#mermaid-svg-7ciUsNjS6HSmOsFQ .icon-shape .label{text-align:center;}#mermaid-svg-7ciUsNjS6HSmOsFQ .node.clickable{cursor:pointer;}#mermaid-svg-7ciUsNjS6HSmOsFQ .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-7ciUsNjS6HSmOsFQ .arrowheadPath{fill:#333333;}#mermaid-svg-7ciUsNjS6HSmOsFQ .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-7ciUsNjS6HSmOsFQ .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-7ciUsNjS6HSmOsFQ .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-7ciUsNjS6HSmOsFQ .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-7ciUsNjS6HSmOsFQ .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-7ciUsNjS6HSmOsFQ .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-7ciUsNjS6HSmOsFQ .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-7ciUsNjS6HSmOsFQ .cluster text{fill:#333;}#mermaid-svg-7ciUsNjS6HSmOsFQ .cluster span{color:#333;}#mermaid-svg-7ciUsNjS6HSmOsFQ div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-7ciUsNjS6HSmOsFQ .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-7ciUsNjS6HSmOsFQ rect.text{fill:none;stroke-width:0;}#mermaid-svg-7ciUsNjS6HSmOsFQ .icon-shape,#mermaid-svg-7ciUsNjS6HSmOsFQ .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-7ciUsNjS6HSmOsFQ .icon-shape p,#mermaid-svg-7ciUsNjS6HSmOsFQ .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-7ciUsNjS6HSmOsFQ .icon-shape .label rect,#mermaid-svg-7ciUsNjS6HSmOsFQ .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-7ciUsNjS6HSmOsFQ .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-7ciUsNjS6HSmOsFQ .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-7ciUsNjS6HSmOsFQ :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} ⚡ 在线:问答链路
🔧 离线:知识库构建
网页/文档
数据清洗
trafilatura/bs4
切分
RecursiveCharacter
TextSplitter
Embedding
向量数据库
Chroma/Milvus
倒排索引
ES/BM25
用户问题
双路召回
向量 + BM25
RRF 融合
rerank 精排
拼接 Prompt
LLM 生成
七、经验总结
- 检索准 ≠ 回答对:生成端模型能力不够时,检索再准也白搭,小模型会编造数字。
- 切分策略是效果分水岭 :默认用
RecursiveCharacterTextSplitter;结构切分必须配超长兜底。 - embedding 模型有输入上限(text-embedding-v3 是 8192),超长块要在切分阶段消灭。
- 向量数据库不是可选品:持久化、ANN 索引、元数据过滤,少一个都上不了生产。
- 混合检索 + rerank 是标配:向量管语义,BM25 管关键词,RRF 融合,rerank 精排。
- 验证结论要回到原始语料 :不要只看检索距离就下结论,grep 一下原文最靠谱
(本次实验中 "qwen-max" 在语料里根本不存在,文档里已更新为 "qwen3.8-max")。
附:实验环境与成本
| 项目 | 选择 | 成本 |
|---|---|---|
| Embedding | 阿里云百炼 text-embedding-v3 | 全部实验 < 0.1 元 |
| 对话 | qwen-turbo(最便宜档) | 单次问答约 0.001 元 |
| 向量库 | ChromaDB 0.4.22(嵌入式) | 免费 |
| BM25 | rank-bm25 + jieba | 免费 |
| 语料 | 阿里云百炼官方文档 3 篇(5.2 万字) | 免费 |
全部代码 ~500 行,核心依赖仅 openai SDK + chromadb + numpy + rank-bm25。
动手清单:改切分参数做破坏实验 → 用关键词/语义两类问题测试检索差异 → 观察 rerank 前后排名变化。