Skip to content

向量数据库实战(Milvus/Chroma)

从 Embedding 的第一个维度到 RAG 系统的最后一公里——手把手带你玩转向量数据库。


8. 实战:构建完整的 RAG 知识库问答系统

前 7 章我们学了向量、Embedding、索引、Chroma、Milvus、混合检索、Reranker——现在是把它们串起来的时候。

这一章,我们从零构建一个完整的 RAG 知识库问答系统:用户上传文档 → 系统自动解析、分块、向量化、存储 → 用户提问 → 系统检索相关内容 → LLM 生成回答。


8.1 RAG 架构与数据流全景

完整数据流

RAG 系统全链路:

  ═══ 离线阶段:文档入库 ═══

  📄 原始文档                     💾 向量数据库
  (PDF/Markdown/TXT)              (Chroma / Milvus)
       │                               ▲
       │ ① 文档解析                     │ ④ 存储
       ▼                               │
  📝 纯文本                        🔢 向量 + 元数据
       │                               ▲
       │ ② 智能分块                     │ ③ Embedding
       ▼                               │
  📋 文本块列表  ─────────────────────────┘
  (每块 200-500 字)

  ═══ 在线阶段:用户问答 ═══

  👤 用户提问

       │ ① 问题向量化

  🔍 向量数据库检索 Top-K

       │ ② (可选) Reranker 重排序

  📋 相关文档片段

       │ ③ 拼装 Prompt

  ┌─────────────────────────────────────┐
  │ System: 你是一个知识库助手...         │
  │ Context: [检索到的相关文档片段]       │
  │ Question: [用户的问题]               │
  └─────────────────────────────────────┘

       │ ④ LLM 生成回答

  💬 返回用户(基于知识库的准确回答)

技术选型

本章实战的技术栈:

  组件              选型               原因
  ──────────────────────────────────────────────
  文档解析          Python 内置        简单可靠
  文本分块          RecursiveCharacter  LangChain 最常用的分块器
                    TextSplitter
  Embedding 模型    OpenAI small       省事 + 效果好
                    或 BGE-large-zh    免费 + 中文强
  向量数据库        Chroma / Milvus    两种都演示
  重排序            bge-reranker       可选,提升精度
  LLM              OpenAI GPT-4o      生成回答
  ──────────────────────────────────────────────

  依赖安装:
  pip install chromadb pymilvus openai langchain
  pip install sentence-transformers  # 如果用 BGE

8.2 文档解析与智能分块策略

分块(Chunking)是 RAG 系统中最容易被忽视但影响最大的环节。分块策略直接决定了检索质量。

为什么要分块

为什么不能把整篇文档直接向量化?

  问题 1:超出模型长度限制
  ─────────────────────────────────────
    BGE-large-zh 最大输入:512 token ≈ 250 中文字
    一篇文档可能有 5000 字 → 超了 20 倍
    → 必须切成小块

  问题 2:长文本的向量质量差
  ─────────────────────────────────────
    一篇 5000 字的文档涉及多个主题
    → 向量化后变成一个"平均"的语义表示
    → 搜索时哪个主题都匹配不好
    
    切成 10 个 500 字的块
    → 每个块只讲一个子主题
    → 向量化后语义更集中、搜索更精准

  问题 3:LLM 上下文窗口有限
  ─────────────────────────────────────
    检索 5 个块 × 每块 500 字 = 2500 字
    加上系统提示和用户问题 ≈ 3000 字
    → 刚好在大多数模型的最佳处理范围内

分块策略对比

四种常见分块策略:

  1. 固定长度分块(最简单)
  ─────────────────────────────────────
     每 500 字切一刀,不管内容
     ✅ 简单快速
     ❌ 可能把一句话切断

  2. 递归字符分块(最常用) ⭐
  ─────────────────────────────────────
     按层级分隔符切割:\n\n → \n → 句号 → 逗号
     ✅ 尽量保持段落完整
     ✅ LangChain 默认方案
     ❌ 对代码块不太友好

  3. 语义分块(最智能)
  ─────────────────────────────────────
     用 Embedding 判断相邻句子的语义相似度
     相似度骤降的地方 = 主题切换点
     ✅ 语义最连贯
     ❌ 计算量大,速度慢

  4. 按结构分块(适合特定格式)
  ─────────────────────────────────────
     Markdown → 按标题层级切
     代码 → 按函数/类切
     HTML → 按标签切
     ✅ 充分利用文档结构
     ❌ 需要针对格式做适配

实战代码:递归字符分块

python
from langchain.text_splitter import RecursiveCharacterTextSplitter

# 创建分块器
splitter = RecursiveCharacterTextSplitter(
    chunk_size=500,        # 每块最大 500 字符
    chunk_overlap=100,     # 相邻块重叠 100 字符(防止切断上下文)
    separators=[           # 按优先级尝试切割
        "\n\n",            # 优先在段落之间切
        "\n",              # 然后在换行处切
        "。",              # 然后在句号处切
        ",",              # 然后在逗号处切
        " ",               # 最后在空格处切
        ""                 # 兜底:按字符切
    ]
)

# 分块示例
text = """
# Python 异步编程指南

## 1. 什么是异步编程
异步编程是一种并发编程模式,它允许程序在等待 I/O 操作时执行其他任务...
(假设这里有 2000 字的长文本)

## 2. asyncio 基础
asyncio 是 Python 的异步框架,提供了事件循环、协程等核心概念...
"""

chunks = splitter.split_text(text)

for i, chunk in enumerate(chunks):
    print(f"块 {i+1} ({len(chunk)} 字符):")
    print(f"  {chunk[:80]}...")
    print()

分块的黄金参数

参数推荐值说明
chunk_size300-500(中文)太小语义不完整,太大检索不精准
chunk_overlapchunk_size 的 20%防止切断关键上下文
分隔符段落 > 句子 > 逗号优先保持自然语义单元

关键经验:分块没有"一刀切"的最优方案。建议用自己的实际数据测试不同参数,观察检索效果来微调。chunk_size=400 + overlap=80 是一个稳妥的起点。

按文档类型选择分块策略

不同文档格式适合不同的分块方式:

文档类型推荐策略代码实现
纯文本 / TXT递归字符分块RecursiveCharacterTextSplitter
Markdown按标题层级分块MarkdownHeaderTextSplitter
Python / JS 代码按函数/类分块RecursiveCharacterTextSplitter.from_language()
PDF先提取文本再递归分块PyPDFLoader + 递归分块
HTML按标签层级分块HTMLSectionSplitter
表格数据每行/每条记录为一块自定义(行级拆分)
python
from langchain.text_splitter import (
    MarkdownHeaderTextSplitter,
    RecursiveCharacterTextSplitter,
    Language,
)

# Markdown 分块:按标题层级拆分
md_splitter = MarkdownHeaderTextSplitter(
    headers_to_split_on=[
        ("#", "h1"),
        ("##", "h2"),
        ("###", "h3"),
    ]
)
md_chunks = md_splitter.split_text(markdown_text)
# 每个 chunk 带标题元数据:{"h1": "xxx", "h2": "yyy"}

# Python 代码分块:按函数/类拆分
code_splitter = RecursiveCharacterTextSplitter.from_language(
    language=Language.PYTHON,
    chunk_size=1000,
    chunk_overlap=100,
)
code_chunks = code_splitter.split_text(python_code)

语义分块实战

语义分块不按固定字符数切割,而是检测主题切换点——当相邻句子的语义相似度骤降时,就在那里切一刀。

python
from langchain_experimental.text_splitter import SemanticChunker
from langchain_openai import OpenAIEmbeddings

# 基于语义相似度的分块
semantic_splitter = SemanticChunker(
    embeddings=OpenAIEmbeddings(),
    breakpoint_threshold_type="percentile",  # 或 "standard_deviation"
    breakpoint_threshold_amount=75,          # 分位数阈值
)

chunks = semantic_splitter.split_text(long_document)
# 主题连贯的段落会被保持在一起
# 主题切换的地方自动切割
语义分块 vs 递归分块:

  递归分块:每块大小一致(~400 字),但可能切断语义
  语义分块:每块大小不一(100-800 字),但语义最连贯

  什么时候用语义分块?
  → 文档主题跳跃频繁(如会议纪要、论坛帖子)
  → 对检索精度要求极高
  → 能接受更慢的预处理速度

进阶分块技巧

1. Parent Document Retriever(父子文档)
─────────────────────────────────────
  小块检索,大块返回:
  
  原文档 → 拆成大块(2000 字) → 每个大块再拆成小块(400 字)
  检索时用小块匹配(精准)
  返回时给 LLM 整个大块(上下文完整)
  
  实现:LangChain ParentDocumentRetriever

2. 上下文增强(Contextual Chunking)
─────────────────────────────────────
  每个块前面自动加上文档级摘要:
  
  原始块:"asyncio.gather 可以并发执行多个协程..."
  增强块:"[本文档讲 Python 异步编程] asyncio.gather 可以并发执行..."
  
  效果:检索时匹配更准确,因为每个块都携带了全局上下文

3. 滑动窗口分块
─────────────────────────────────────
  重叠率设为 50% 而非 20%:
  chunk_size=400, overlap=200
  
  生成更多块,但确保每个关键信息至少被两个块覆盖
  适合问答精度要求极高的场景(如医疗、法律)

分块质量自检清单

分完块后,随机抽取 20 个块检查:

  ✅ 每个块能独立理解吗?(不需要上下文就能看懂)
  ✅ 核心信息有没有被切断?(一个概念被分到两个块里)
  ✅ 块的大小分布合理吗?(不要出现太多 < 50 字的碎片)
  ✅ 代码块是完整的吗?(函数没有被从中间切开)
  ✅ 表格是完整的吗?(表头和数据在同一个块里)
  
  如果有 3 个以上不通过 → 需要调整分块策略或参数

8.3 Chroma 版 RAG 实现

最简方案——用 Chroma 快速搭建一个可用的 RAG 系统。

python
import chromadb
from chromadb.utils.embedding_functions import SentenceTransformerEmbeddingFunction
from langchain.text_splitter import RecursiveCharacterTextSplitter
from openai import OpenAI

# === 初始化 ===
# Embedding 模型
embedding_fn = SentenceTransformerEmbeddingFunction(
    model_name="BAAI/bge-large-zh-v1.5"
)

# Chroma 持久化存储
chroma_client = chromadb.PersistentClient(path="./rag_chroma_data")
collection = chroma_client.get_or_create_collection(
    name="knowledge_base",
    embedding_function=embedding_fn,
    metadata={"hnsw:space": "cosine"}
)

# 文本分块器
splitter = RecursiveCharacterTextSplitter(
    chunk_size=400, chunk_overlap=80,
    separators=["\n\n", "\n", "。", ",", " ", ""]
)

# LLM
llm_client = OpenAI()

文档入库流程

python
def ingest_document(file_path: str, source: str = "unknown"):
    """将文档解析、分块、向量化后存入 Chroma"""

    # 1. 读取文档
    with open(file_path, "r", encoding="utf-8") as f:
        text = f.read()

    # 2. 分块
    chunks = splitter.split_text(text)
    print(f"文档 {source} 分成 {len(chunks)} 个块")

    # 3. 存入 Chroma(自动向量化)
    collection.add(
        documents=chunks,
        ids=[f"{source}_chunk_{i}" for i in range(len(chunks))],
        metadatas=[{"source": source, "chunk_index": i} for i in range(len(chunks))]
    )

# 导入文档
ingest_document("./docs/python_async.md", source="python_async")
ingest_document("./docs/fastapi_guide.md", source="fastapi_guide")
print(f"知识库总文档数:{collection.count()}")

问答流程

python
def ask(question: str, top_k: int = 5) -> str:
    """基于知识库回答问题"""

    # 1. 检索相关文档
    results = collection.query(
        query_texts=[question],
        n_results=top_k
    )

    # 2. 拼装上下文
    context = "\n\n---\n\n".join(results["documents"][0])

    # 3. 构建 Prompt
    prompt = f"""你是一个知识库助手。请根据以下参考资料回答用户的问题。
如果参考资料中没有相关信息,请如实告知"知识库中暂无相关内容"。

## 参考资料
{context}

## 用户问题
{question}

## 回答要求
- 基于参考资料回答,不要编造信息
- 如果涉及代码,给出可运行的示例
- 回答要简洁明了"""

    # 4. LLM 生成回答
    response = llm_client.chat.completions.create(
        model="gpt-4o",
        messages=[{"role": "user", "content": prompt}],
        temperature=0.3
    )

    return response.choices[0].message.content

# 测试
answer = ask("Python 中怎么使用 asyncio 实现并发?")
print(answer)

Chroma 版优势:代码极简,20 分钟就能搭起来。适合个人项目、原型验证、小型知识库(< 10 万文档块)。

8.4 Milvus 版 RAG 实现

生产级方案——用 Milvus + 混合检索 + Reranker 构建高质量 RAG。

python
from pymilvus import (
    connections, Collection, CollectionSchema,
    FieldSchema, DataType, AnnSearchRequest, RRFRanker
)
from pymilvus.model.hybrid import BGEM3EmbeddingFunction
from sentence_transformers import CrossEncoder
from langchain.text_splitter import RecursiveCharacterTextSplitter
from openai import OpenAI

# === 初始化 ===
connections.connect(host="localhost", port="19530")

# BGE-M3:同时生成 Dense + Sparse 向量
bge_m3 = BGEM3EmbeddingFunction(model_name="BAAI/bge-m3", device="cpu")

# Reranker
reranker = CrossEncoder("BAAI/bge-reranker-v2-m3", max_length=512)

# LLM
llm_client = OpenAI()

# 分块器
splitter = RecursiveCharacterTextSplitter(
    chunk_size=400, chunk_overlap=80,
    separators=["\n\n", "\n", "。", ",", " ", ""]
)

Schema 与 Collection 创建

python
fields = [
    FieldSchema(name="id", dtype=DataType.INT64, is_primary=True, auto_id=True),
    FieldSchema(name="content", dtype=DataType.VARCHAR, max_length=2000),
    FieldSchema(name="source", dtype=DataType.VARCHAR, max_length=256),
    FieldSchema(name="chunk_index", dtype=DataType.INT64),
    FieldSchema(name="dense_embedding", dtype=DataType.FLOAT_VECTOR, dim=1024),
    FieldSchema(name="sparse_embedding", dtype=DataType.SPARSE_FLOAT_VECTOR),
]

schema = CollectionSchema(fields, description="RAG 知识库")
collection = Collection("rag_knowledge_base", schema)

# 建索引
collection.create_index("dense_embedding", {
    "metric_type": "COSINE", "index_type": "HNSW",
    "params": {"M": 16, "efConstruction": 200}
})
collection.create_index("sparse_embedding", {
    "metric_type": "IP", "index_type": "SPARSE_INVERTED_INDEX",
    "params": {"drop_ratio_build": 0.2}
})

文档入库(含 Dense + Sparse 双向量)

python
def ingest_document_milvus(file_path: str, source: str):
    """文档入库:分块 → 双向量化 → 存入 Milvus"""

    with open(file_path, "r", encoding="utf-8") as f:
        text = f.read()

    chunks = splitter.split_text(text)
    print(f"文档 {source} 分成 {len(chunks)} 个块")

    # BGE-M3 同时生成 Dense 和 Sparse 向量
    embeddings = bge_m3.encode_documents(chunks)

    collection.insert([
        chunks,                                          # content
        [source] * len(chunks),                          # source
        list(range(len(chunks))),                         # chunk_index
        embeddings["dense"],                             # dense_embedding
        embeddings["sparse"],                            # sparse_embedding
    ])
    collection.flush()

问答流程(混合检索 + Rerank)

python
def ask_milvus(question: str, top_k: int = 5) -> str:
    """生产级 RAG 问答:混合检索 + Rerank + LLM"""

    collection.load()

    # 1. 生成查询的 Dense + Sparse 向量
    query_emb = bge_m3.encode_queries([question])

    # 2. 混合检索
    dense_req = AnnSearchRequest(
        data=query_emb["dense"], anns_field="dense_embedding",
        param={"metric_type": "COSINE", "params": {"ef": 128}},
        limit=20
    )
    sparse_req = AnnSearchRequest(
        data=query_emb["sparse"], anns_field="sparse_embedding",
        param={"metric_type": "IP", "params": {}},
        limit=20
    )
    candidates = collection.hybrid_search(
        reqs=[dense_req, sparse_req],
        ranker=RRFRanker(k=60), limit=20,
        output_fields=["content", "source"]
    )

    # 3. Reranker 重排序
    docs = [hit.entity.get("content") for hit in candidates[0]]
    pairs = [[question, doc] for doc in docs]
    scores = reranker.predict(pairs)

    ranked = sorted(zip(candidates[0], scores), key=lambda x: x[1], reverse=True)
    top_docs = ranked[:top_k]

    # 4. 拼装 Prompt
    context = "\n\n---\n\n".join([hit.entity.get("content") for hit, _ in top_docs])

    prompt = f"""你是一个知识库助手。请根据以下参考资料回答用户的问题。
如果参考资料中没有相关信息,请如实告知。

## 参考资料
{context}

## 用户问题
{question}"""

    # 5. LLM 生成
    response = llm_client.chat.completions.create(
        model="gpt-4o",
        messages=[{"role": "user", "content": prompt}],
        temperature=0.3
    )

    return response.choices[0].message.content

# 测试
answer = ask_milvus("BGE-M3 模型支持哪些语言?")
print(answer)

Milvus 版优势:混合检索 + Reranker 双重保障,检索精度远超 Chroma 纯语义方案。适合中大型生产环境,百万级文档。

8.5 效果评测:召回率、准确率、延迟

做完 RAG 系统后,最重要的事是评估它到底好不好用

三种方案的检索效果对比

评测设置:
  知识库:100 篇技术文档,分成约 2000 个块
  测试集:50 个人工标注的 QA 对
  评测指标:Recall@5(Top-5 中包含正确答案的比例)

  ┌──────────────────────────┬───────────┬──────────┬──────────┐
  │ 方案                      │ Recall@5  │ 平均延迟  │ 复杂度   │
  ├──────────────────────────┼───────────┼──────────┼──────────┤
  │ Chroma 纯语义搜索          │ 72%       │ 15ms     │ ⭐       │
  │ Milvus 纯语义搜索          │ 74%       │ 8ms      │ ⭐⭐     │
  │ Milvus 混合检索            │ 85%       │ 12ms     │ ⭐⭐⭐   │
  │ Milvus 混合检索 + Reranker │ 91%       │ 55ms     │ ⭐⭐⭐⭐ │
  └──────────────────────────┴───────────┴──────────┴──────────┘

  关键发现:
    → 混合检索比纯语义提升 ~13%(主要在精确术语查询上)
    → Reranker 再提升 ~6%(主要在模糊查询上)
    → Reranker 增加了 ~40ms 延迟,但仍在可接受范围

评测指标说明

指标含义怎么算
Recall@KTop-K 结果中包含正确答案的比例命中数 / 总查询数
MRR正确答案的平均排名倒数1/rank 的平均值
NDCG考虑排名权重的准确率排名越靠前权重越高
端到端准确率LLM 最终回答的正确率人工评判或 LLM 评判
延迟从提问到返回答案的总耗时检索 + Rerank + LLM 生成

快速评测脚本

python
def evaluate_retrieval(test_qa_pairs, search_fn, top_k=5):
    """评估检索召回率"""
    hits = 0

    for question, expected_source in test_qa_pairs:
        results = search_fn(question, top_k=top_k)

        # 检查 Top-K 结果中是否包含预期来源
        sources = [r.get("source", "") for r in results]
        if expected_source in sources:
            hits += 1

    recall = hits / len(test_qa_pairs)
    print(f"Recall@{top_k}: {recall:.2%} ({hits}/{len(test_qa_pairs)})")
    return recall

# 测试数据示例
test_pairs = [
    ("Python 协程的工作原理是什么?", "python_async"),
    ("FastAPI 怎么处理文件上传?", "fastapi_guide"),
    ("asyncio.gather 和 asyncio.wait 有什么区别?", "python_async"),
    # ... 更多测试用例
]

真实性能基准参考

不同规模下的延迟参考(基于实测):

  知识库规模      检索延迟    Rerank 延迟   LLM 延迟     总延迟
  ──────────────────────────────────────────────────────────
  1000 块         5ms        30ms         800ms        ~835ms
  1 万块          8ms        30ms         800ms        ~838ms
  10 万块         12ms       30ms         800ms        ~842ms
  100 万块        25ms       30ms         800ms        ~855ms

  关键发现:
    → 检索延迟随数据量增长很慢(归功于 HNSW 索引)
    → Reranker 延迟固定(只处理 Top-20 候选)
    → LLM 生成延迟是瓶颈(占总延迟的 90%+)
    → 结论:向量数据库的搜索速度不是系统瓶颈

优化方向:如果总延迟不满足要求,优先优化 LLM 延迟(换用更快的模型、启用流式输出)。向量数据库的检索部分通常不需要额外优化。


本章小结

知识点要点
RAG 全链路解析 → 分块 → 向量化 → 存储 → 检索 → Rerank → LLM 生成
分块策略递归字符分块最常用,chunk_size=400 + overlap=80
Chroma 版20 分钟搭建,适合原型和小型项目
Milvus 版混合检索 + Reranker,Recall@5 可达 91%
效果差异混合检索比纯语义提升 ~13%,Reranker 再提升 ~6%
性能瓶颈LLM 生成延迟(~800ms)远大于检索延迟(~10ms)
评测方法Recall@K 评估检索质量,人工评判评估回答质量

下一章预告:生产级优化——性能、成本、运维。我们会讨论百万级数据的写入优化、查询调优、Embedding 缓存、数据更新策略、以及从 Chroma 迁移到 Milvus 的实操路径。

坚持是一种品格