← 返回博客
·AI技术

RAG 系统上线三个月后,我终于明白为什么大家都在做语义切片

从固定长度切片到语义切片的踩坑实录,分享如何提升 RAG 系统准确率

#RAG#LLM#向量检索

# RAG 系统上线三个月后,我终于明白为什么大家都在做语义切片

我们公司的知识库问答系统上线三个月了。前两个月,准确率稳定在 60% 左右,用户投诉"答非所问"是家常便饭。上个月我狠下心重构了文档切片逻辑,准确率直接冲到 85%。

问题出在哪?切片策略。

固定长度切片:简单但不靠谱

最初的实现是这样的:

def split_text(text, chunk_size=500, overlap=50):

"""按固定字符数切分"""

chunks = []

for i in range(0, len(text), chunk_size - overlap):

chunks.append(text[i:i + chunk_size])

return chunks

这个方法有几个致命问题:

  • **语义被切断**:一句话可能被切成两半,检索时上下文不完整
  • 2. **主题漂移**:一个 chunk 里可能混了三个不同的话题

    3. **噪声过多**:有些 chunk 信息密度极低(比如目录页、版权声明)

    实测下来,用户问"如何配置 API Key",系统返回了包含"API"这个词的所有 chunk,但真正讲配置的那段被切成两半,拼起来才能看懂。

    语义切片:让 AI 帮你断句

    重构后,我改用 LLM 做语义分析:

    from openai import OpenAI

    client = OpenAI()

    def semantic_split(text):

    """用 LLM 识别语义边界"""

    prompt = f"""

    分析以下文本,在语义完整的段落边界处插入 [SPLIT] 标记。

    规则:

  • 同一主题的内容不要拆开
  • 2. 每个 chunk 控制在 200-500 字

    3. 如果某段超过 500 字,在逻辑停顿处拆分

    文本:

    {text}

    """

    response = client.chat.completions.create(

    model="gpt-4o-mini",

    messages=[{"role": "user", "content": prompt}]

    )

    marked_text = response.choices[0].message.content

    return [c.strip() for c in marked_text.split('[SPLIT]') if c.strip()]

    效果立竿见影。同一个问题"如何配置 API Key",系统现在能返回完整的配置步骤,不再需要用户自己拼凑信息。

    但这个方法有个问题:成本。每篇文档都要调一次 LLM,我们 3000 篇文档切下来花了 50 美元。对于文档更新频繁的场景,这个成本会持续累积。

    折中方案:规则 + 语义

    后来我做了个混合方案:

  • 先用规则做预处理:按标题、代码块、表格等结构切分
  • 2. 对超过阈值的块,再用 LLM 做语义切分

    import re

    def hybrid_split(text, max_chunk_size=500):

    """混合切片策略"""

    # 先按 Markdown 标题切

    sections = re.split(r'\n(?=#{1,3} )', text)

    chunks = []

    for section in sections:

    if len(section) <= max_chunk_size:

    chunks.append(section)

    else:

    # 大块再用语义切

    chunks.extend(semantic_split(section))

    return chunks

    这个方案把 LLM 调用次数减少了 70%,成本降到可接受范围。

    别忘了 overlap

    不管用什么切片策略,overlap 都是必须的。原因很简单:用户提问的关键词可能正好跨了两个 chunk。

    def add_overlap(chunks, overlap_size=50):

    """给相邻 chunk 添加重叠"""

    result = []

    for i, chunk in enumerate(chunks):

    if i > 0:

    # 从上一个 chunk 末尾取 overlap

    prefix = chunks[i-1][-overlap_size:] if len(chunks[i-1]) > overlap_size else chunks[i-1]

    chunk = prefix + chunk

    result.append(chunk)

    return result

    overlap 不用太大,50-100 字就够了。太大反而会引入噪声。

    一个容易被忽略的问题:元数据

    重构时我还加了一个改进:给每个 chunk 附带元数据。

    def create_chunks_with_metadata(doc, chunks):

    """给 chunk 添加来源信息"""

    return [

    {

    "content": chunk,

    "source": doc["url"],

    "title": doc["title"],

    "section": extract_section_title(chunk), # 提取最近标题

    "doc_type": doc["type"] # api/tutorial/faq 等

    }

    for chunk in chunks

    ]

    这些元数据有两个用途:

  • **回答溯源**:用户可以看到答案来自哪篇文档的哪个章节
  • 2. **过滤检索**:可以根据文档类型做精准检索(比如只搜 API 文档)

    元数据也会参与 embedding,所以提取时要简洁,不要塞太多无关信息。

    实测数据对比

    | 指标 | 固定切片 | 语义切片 | 混合切片 |

    |------|---------|---------|---------|

    | 准确率 | 62% | 88% | 85% |

    | 平均 chunk 数 | 4500 | 3200 | 3400 |

    | 切片耗时 | 2min | 45min | 12min |

    | 成本 | $0 | $50 | $15 |

    语义切片效果最好,但成本和时间开销最大。混合方案是性价比之选。

    一些踩坑经验

  • **代码块要整体保留**:把代码切成两半是最蠢的做法,检索时会返回残缺的代码
  • 2. **表格也要整体保留**:表格切开后,列名和内容分离,完全无法理解

    3. **长文档要分段处理**:超过 1 万字的文档,先按大章节拆,再逐章节切片

    4. **定期评估切片质量**:用真实问答日志回测,看检索召回率是否下降

    我们现在的流程是:每周跑一次自动化测试,用过去 100 个真实问题验证检索效果。准确率掉到 80% 以下就报警,排查是文档更新导致的切片问题还是 embedding 模型的问题。

    RAG 系统没有银弹,切片策略也是。但至少,别再用固定长度切了——那真的只是在感动自己。