RAG 系统上线三个月后,我终于明白为什么大家都在做语义切片
从固定长度切片到语义切片的踩坑实录,分享如何提升 RAG 系统准确率
# 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 系统没有银弹,切片策略也是。但至少,别再用固定长度切了——那真的只是在感动自己。
VkingAI