从零手搓 RAG:一个后端开发的实战学习笔记

不依赖框架,从 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,三个核心思想:

  1. 词越稀有,权重越高(IDF):"API Key"这种词比"的"、"请"值钱得多;
  2. 词频收益递减(TF 饱和):一个词在块里出现 10 次并不比出现 3 次强多少;
  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 生成

七、经验总结

  1. 检索准 ≠ 回答对:生成端模型能力不够时,检索再准也白搭,小模型会编造数字。
  2. 切分策略是效果分水岭 :默认用 RecursiveCharacterTextSplitter;结构切分必须配超长兜底。
  3. embedding 模型有输入上限(text-embedding-v3 是 8192),超长块要在切分阶段消灭。
  4. 向量数据库不是可选品:持久化、ANN 索引、元数据过滤,少一个都上不了生产。
  5. 混合检索 + rerank 是标配:向量管语义,BM25 管关键词,RRF 融合,rerank 精排。
  6. 验证结论要回到原始语料 :不要只看检索距离就下结论,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 前后排名变化。

相关推荐
Jamesvalley1 小时前
【VibeCoding】发发牢骚
ai
星核0penstarry2 小时前
276B 总参 / 12B 激活,8 卡 B200 跑 648 tok/s:Inkling‑Small 技术解读**2
大数据·人工智能·ai
海的辽阔2 小时前
GraphRAG 与 LightRAG 全面解析:传统 RAG 为什么开始走向图检索?
人工智能·rag
Shacoray2 小时前
OpenAI 兼容不等于行为一致:DeepSeek 与 LM Studio 的结构化输出和 Thinking 配置实践
ai
MicrosoftReactor2 小时前
技术速递|GitHub Copilot App 入门指南:快速上手
ai·github·copilot·copilot app
明明真系叻3 小时前
RAG学习(1)
学习·rag
城管不管3 小时前
RabbitMQ死信队列
java·分布式·ai·面试·职场和发展·rabbitmq·agent
城管不管3 小时前
rabbitmq如何保证消息不丢失?解决方案又是什么?
开发语言·ai·面试·职场和发展·rabbitmq·php·agent
盖伦发发4 小时前
AIE-AI Engineering三, 四章总结: 如何评估AI应用
人工智能·ai