更新于

中文 RAG 工程实战:从分块到 Rerank 完整流程


中文 RAG 看起来简单——“嵌入 + 检索 + 拼 prompt”——但工程化做好的不多。常见问题:检索召回低、回答漂移、token 浪费、中文专有名词处理不好。本文给一份完整的中文 RAG 工程实战。

一、流程总览

原始文档 → 解析 → 分块 → embedding → 向量库

用户提问 → embedding → 检索 → rerank → 拼 prompt → LLM → 回答

每一步都有讲究。我们逐个展开。

二、文档解析

中文场景常见格式:PDF、Word、Markdown、网页、Excel。

格式推荐工具
PDF(文字)pdfplumber、pypdf
PDF(扫描图)OCR(PaddleOCR、Tesseract)
Wordpython-docx
Markdown直接读
HTMLBeautifulSoup、trafilatura
Excelpandas、openpyxl

关键纪律

  • 解析时保留结构(标题、列表、表格)
  • 表格转换成 markdown 表格保留对齐
  • 代码块明确标记(``` 包围)

三、分块(Chunking)

最容易做错的一步。基础三种策略:

1. 固定长度

def chunk_fixed(text, size=500, overlap=50):
    chunks = []
    for i in range(0, len(text), size - overlap):
        chunks.append(text[i:i + size])
    return chunks

简单但对中文不友好——会切断词、句子。

2. 按句子切

import re

def split_sentences(text):
    # 中文句末标点
    return [s for s in re.split(r"(?<=[。!?\n])", text) if s.strip()]

def chunk_by_sentence(text, max_size=500):
    sents = split_sentences(text)
    chunks, cur, cur_len = [], [], 0
    for s in sents:
        if cur_len + len(s) > max_size and cur:
            chunks.append("".join(cur))
            cur, cur_len = [], 0
        cur.append(s)
        cur_len += len(s)
    if cur:
        chunks.append("".join(cur))
    return chunks

中文场景更合适。

3. 语义分块(Semantic)

用 embedding 找语义断点:

import numpy as np

def semantic_chunk(text, threshold=0.7):
    sents = split_sentences(text)
    embs = [embed(s) for s in sents]
    chunks = [[sents[0]]]
    for i in range(1, len(sents)):
        sim = cosine(embs[i-1], embs[i])
        if sim < threshold:
            chunks.append([])
        chunks[-1].append(sents[i])
    return ["".join(c) for c in chunks]

效果好但成本高。文档量小时用。

推荐配置

场景sizeoverlap策略
技术文档500–800 字50–100 字按句切
长篇小说800–1200 字100–200 字按段切
代码按函数切0AST 切
表格整张表0不切

四、Embedding 选择

详见 Embeddings API 国内对比。简化版:

  • 预算紧 + 文档量大:text-embedding-3-small
  • 召回质量优先:voyage-3-large 或 text-embedding-3-large
  • 自部署 / 数据不能出局:BGE-M3

通过中转调用:

from openai import OpenAI

client = OpenAI(base_url="https://yotradeapi.com/v1", api_key="sk-yo-...")
resp = client.embeddings.create(model="text-embedding-3-large", input=chunks)
embs = [d.embedding for d in resp.data]

五、向量库

选择适用
Chroma本地小项目,零运维
Qdrant中等规模,开源稳定
Milvus大规模
PineconeSaaS,零运维
Postgres + pgvector已有 pg 设施
Redis Vector已有 redis

中文 RAG 不需要花哨的库。500k 条以下用 Chroma,500k–10M 用 Qdrant,再大用 Milvus

import chromadb
client = chromadb.PersistentClient(path="./data")
coll = client.get_or_create_collection("docs")
coll.add(
    ids=[str(i) for i in range(len(chunks))],
    documents=chunks,
    embeddings=embs,
    metadatas=[{"source": "doc1.pdf", "page": p} for p in pages],
)

六、检索

不要只用纯向量检索:

# 混合:向量 + BM25
from rank_bm25 import BM25Okapi

# 准备
bm25 = BM25Okapi([list(c) for c in chunks])

def hybrid_search(query, top_k=10):
    # 向量检索
    q_emb = client.embeddings.create(model=EMB_MODEL, input=[query]).data[0].embedding
    vec_results = coll.query(query_embeddings=[q_emb], n_results=top_k)
    # BM25 检索
    bm25_scores = bm25.get_scores(list(query))
    bm25_top = np.argsort(bm25_scores)[-top_k:][::-1]
    # 合并去重
    combined = list(set(vec_results['ids'][0] + [str(i) for i in bm25_top]))
    return combined

中文场景混合检索的召回比纯向量高 8–15 个百分点

七、Rerank

检索回来 top-20,rerank 重排取 top-5:

resp = client.chat.completions.create(
    model="rerank-3-large",  # 看中转支持
    extra_body={
        "query": query,
        "documents": [doc for doc in retrieved_docs],
        "top_k": 5,
    },
)

实测 rerank 后 top-3 准确率提升 10–15 个百分点。代价:每次提问多调一次 API,成本 +20–30%。

八、拼 prompt

def build_prompt(query, top_docs):
    return f"""根据下面的资料回答问题。
如果资料中没有答案,明确说"资料中没有相关信息",不要编造。

# 资料
{chr(10).join(f"[{i+1}] {d}" for i, d in enumerate(top_docs))}

# 问题
{query}

# 要求
- 引用资料编号 [1] [2] 等
- 简洁,不超过 300 字
"""

关键

  • 明确”不知道就说不知道”,减少幻觉
  • 引用编号让答案可追溯
  • 给出输出长度上限

九、整体调用

def rag_answer(query):
    # 1. 检索 top 20
    ids = hybrid_search(query, top_k=20)
    docs = [chunks[int(i)] for i in ids]

    # 2. rerank top 5
    reranked = rerank(query, docs, top_k=5)

    # 3. 生成
    resp = client.chat.completions.create(
        model="claude-sonnet-4-6",
        messages=[
            {"role": "system", "content": "你是严谨的助理。"},
            {"role": "user", "content": build_prompt(query, reranked)},
        ],
    )
    return resp.choices[0].message.content

十、评估

不评估的 RAG 都在裸奔。最小评估:

test_set = [
    {"q": "公司的 RAG 接入文档在哪?", "expected": "docs/rag-onboarding.md"},
    {"q": "支付回调失败怎么排查?", "expected_keywords": ["webhook", "重试"]},
]

for case in test_set:
    answer = rag_answer(case["q"])
    if "expected" in case:
        passed = case["expected"] in answer
    else:
        passed = all(k in answer for k in case["expected_keywords"])
    print(f"{'PASS' if passed else 'FAIL'}: {case['q']}")

每周跑一次回归测试,效果倒退能立刻发现。

十一、常见踩坑

问题处理
召回率低加 BM25 混合、调小 chunk
答案漂移system prompt 强约束、加 rerank
中文专有名词识别差自训 embedding 或换模型
表格数据丢失用专门的 table 解析
同义词检索不到加 query 扩展(LLM 重写 query)

十二、相关阅读

构建 RAG 需要 embedding + chat + rerank 三种接口?YoTradeApi 一把 Key 全覆盖,按上面流程接入。