文本切分器详解与优化

💡 核心亮点

通过实际案例,理解不同文本切分器的工作原理,解决"多人物介绍笔记检索不准"的典型问题。


🎯 问题场景

典型案例:多人物介绍笔记

笔记内容(个人介绍/我的好友.md):

# 我的好友

---

王涛是我的初中同班同学,高中都在韶关市一中读书,是我的好朋友。
巫迪昀是我的初中同班同学,他高中在北江中学读书,是我的好朋友。
钟东是以前我们韶关一中的老师,好像是团委书记。
曾剑戈也是我的初中同班同学,他高中在北江中学读书,是我的好朋友。
王晓捷是我们初一四班班主任,是历史老师。

问题: - 搜索"钟东",相似度只有0.36(低于阈值0.5) - 搜索"你认识钟东吗",相似度只有0.46(低于阈值0.5) - 结果:检索失败,LLM无法回答


🔍 问题根源分析

1️⃣ 当前使用的切分器

配置文件:app/ping/v1_build_index_local.py

from langchain_text_splitters import RecursiveCharacterTextSplitter

def split_text(content, chunk_size=CHUNK_SIZE, chunk_overlap=CHUNK_OVERLAP):
    """使用 LangChain 切分文本"""
    splitter = RecursiveCharacterTextSplitter(
        chunk_size=chunk_size,        # 1500字符
        chunk_overlap=chunk_overlap,  # 200字符
        separators=["\n\n", "\n", "", ""]  # 切分优先级
    )

    chunks = splitter.split_text(content)
    return chunks

2️⃣ RecursiveCharacterTextSplitter的工作原理

切分优先级(从高到低):

separators=["\n\n", "\n", " ", ""]
  ↓
1. 优先按 \n\n(双换行/段落分隔)切分
2. 如果还太长,按 \n(单换行)切分
3. 再按空格切分
4. 最后按字符强行切分

实际切分过程:

输入文本(200字符):
"# 我的好友\n\n---\n\n王涛是...\n巫迪昀是...\n钟东是...\n曾剑戈是...\n王晓捷是..."

切分逻辑:
1. 检查是否有 \n\n?有,但整段才200字符 < CHUNK_SIZE(1500)
2. 不切分!整个作为1个片段

结果:
片段1: "# 我的好友\n\n---\n\n王涛是...巫迪昀是...钟东是...曾剑戈是...王晓捷是..."
       (包含5个人的信息,约200字符)

3️⃣ 为什么相似度低?

向量"稀释"效应:

整个片段的内容向量 = f(王涛 + 巫迪昀 + 钟东 + 曾剑戈 + 王晓捷)

向量化模型会把整段压缩成1024维向量:
- "钟东"只占全文的1/5(约20%)
- 其他4个人的信息占了80%
- 最终向量是"混合"的,不纯粹

搜索时的表现:

用户问:"钟东是谁?"
问题向量:[钟东相关的语义]

笔记向量:[王涛+巫迪昀+钟东+曾剑戈+王晓捷的混合语义]

计算相似度:
- 问题向量中100%是"钟东"
- 笔记向量中只有20%是"钟东"
- 相似度 = 0.36(很低!)

📊 实测数据

其他类似案例

查询数据库中"只有1个片段但内容较长"的笔记:

SELECT title, COUNT(*) as cnt 
FROM note_vectors 
GROUP BY title 
HAVING cnt = 1
ORDER BY length(text) DESC
LIMIT 10;

结果:

笔记标题 片段数 字符数 问题
语音交互为何选择HTTP而非WebSocket 1 1407 内容完整,无需切分 ✅
"标准" 1 1330 内容完整 ✅
"官方语言" 1 1258 内容完整 ✅
我的好友 1 200 ❌ 多人物混合,应该拆分

结论: - 长度<1500的笔记不会被切分 - 但如果内容包含多个独立主题,应该拆分


🔧 解决方案

方案1:优化笔记格式(立即生效)✅ 推荐

原理:利用\n\n(双换行)触发切分

修改前(不切分):

# 我的好友

---

王涛是我的初中同班同学,高中都在韶关市一中读书,是我的好朋友。
巫迪昀是我的初中同班同学,他高中在北江中学读书,是我的好朋友。
钟东是以前我们韶关一中的老师,好像是团委书记。
曾剑戈也是我的初中同班同学,他高中在北江中学读书,是我的好朋友。
王晓捷是我们初一四班班主任,是历史老师。

修改后(会切成5个片段):

# 我的好友

---

王涛是我的初中同班同学,高中都在韶关市一中读书,是我的好朋友。

巫迪昀是我的初中同班同学,他高中在北江中学读书,是我的好朋友。

钟东是以前我们韶关一中的老师,好像是团委书记。

曾剑戈也是我的初中同班同学,他高中在北江中学读书,是我的好朋友。

王晓捷是我们初一四班班主任,是历史老师。

关键改动:每段之间加空行(\n\n)

效果对比:

指标 修改前 修改后
片段数 1个 5个
"钟东"片段内容 混合5人信息 纯钟东信息
搜索"钟东"相似度 0.36 ❌ 0.75+ ✅
检索成功率 失败 成功

重新向量化:

cd /app
python3 app/ping/v1_build_index_local.py

方案2:拆分成独立笔记(最佳实践)⭐ 强烈推荐

目录结构:

个人介绍/
├── README.我.md(索引文件)
├── 好友-王涛.md
├── 好友-巫迪昀.md
├── 好友-钟东.md
├── 好友-曾剑戈.md
└── 好友-王晓捷.md

示例(好友-钟东.md):

---
title: 钟东
date: 2026-05-10
tags: ["个人介绍", "好友", "老师"]
description: 韶关一中团委书记
---

# 钟东

钟东是以前我们韶关一中的老师,好像是团委书记。

优势: - ✅ 每个笔记都是独立向量,语义纯粹 - ✅ 检索准确率最高 - ✅ 方便管理和更新 - ✅ 符合笔记规范(一篇笔记一个主题)


方案3:切换到MarkdownHeaderSplitter(长期方案)🔜

什么是MarkdownHeaderSplitter?

按Markdown标题层级切分,每个标题下的内容作为独立片段。

配置示例:

from langchain_text_splitters import MarkdownHeaderTextSplitter

def split_text_by_markdown(content):
    """按Markdown标题切分"""
    # 定义切分层级
    headers_to_split_on = [
        ("#", "Header1"),
        ("##", "Header2"),
        ("###", "Header3"),
    ]

    splitter = MarkdownHeaderTextSplitter(
        headers_to_split_on=headers_to_split_on
    )

    chunks = splitter.split_text(content)

    # 提取文本内容
    return [chunk.page_content for chunk in chunks]

切分效果:

输入:
# 我的好友

## 王涛
王涛是我的初中同班同学...

## 钟东
钟东是以前我们韶关一中的老师...

输出(2个片段):
片段1: "王涛是我的初中同班同学..."
片段2: "钟东是以前我们韶关一中的老师..."

优势: - ✅ 按语义切分,不依赖空行 - ✅ 自动识别标题层级 - ✅ 片段更连贯

劣势: - ⚠️ 需要修改代码 - ⚠️ 必须使用标题(## 钟东) - ⚠️ 可能产生过长片段(如果某个标题下内容很多)


📋 切分器对比

特性 RecursiveCharacterTextSplitter MarkdownHeaderSplitter
切分依据 字符数 + 分隔符 Markdown标题
是否需要空行 ✅ 是(\n\n) ❌ 否
语义连贯性 一般 优秀
配置复杂度 简单 中等
适用场景 通用文本 Markdown文档
片段长度控制 精确(chunk_size) 不精确(依赖内容)
当前项目使用 ✅ 是 ❌ 未使用

🎓 最佳实践建议

1️⃣ 笔记编写规范

多人物/多主题笔记:

# 主题

## 子主题1
内容...

## 子主题2
内容...

或者:

# 主题

内容1...

(空行)

内容2...

(空行)

内容3...

2️⃣ 切分策略选择

场景 推荐方案 原因
个人介绍、人物列表 拆分独立笔记 语义最纯粹
技术文档、教程 MarkdownHeaderSplitter 按章节切分
随笔、日记 RecursiveCharacterTextSplitter 按段落切分

3️⃣ 检查清单

向量化前检查:


🔬 实验验证

测试脚本

cd /app
python3 << 'EOF'
import sqlite3

conn = sqlite3.connect('db/ping.db')
cursor = conn.cursor()

# 查询"我的好友"的片段
cursor.execute("""
    SELECT title, text, length(text) as len
    FROM note_vectors
    WHERE title='我的好友'
""")
rows = cursor.fetchall()

print(f'找到 {len(rows)} 个片段:\n')
for i, (title, text, length) in enumerate(rows, 1):
    print(f'片段 {i} ({length}字符):')
    print(f'{text[:200]}...')
    print('-' * 60)

conn.close()
EOF

优化前后对比

优化前:

找到 1 个片段:
片段 1 (200字符):
# 我的好友
王涛是...巫迪昀是...钟东是...曾剑戈是...王晓捷是...

优化后(加空行或拆分):

找到 5 个片段:
片段 1 (40字符): 王涛是我的初中同班同学...
片段 2 (42字符): 巫迪昀是我的初中同班同学...
片段 3 (28字符): 钟东是以前我们韶关一中的老师...
片段 4 (42字符): 曾剑戈也是我的初中同班同学...
片段 5 (26字符): 王晓捷是我们初一四班班主任...

💡 核心要点总结

1️⃣ RecursiveCharacterTextSplitter 依赖 \n\n 切分
   ↓
2️⃣ 没有空行的多段文本会被合并成1个片段
   ↓
3️⃣ 混合主题导致向量"稀释",相似度降低
   ↓
4️⃣ 解决方案:
   - 方案1:每段加空行(快速)
   - 方案2:拆分独立笔记(最佳)
   - 方案3:切换MarkdownHeaderSplitter(长期)
   ↓
5️⃣ 重新向量化,测试检索效果

🔗 相关文档


📝 笔记创建:2026-05-19
🔄 最后更新:2026-05-19
🏷️ 标签:#向量化 #文本切分 #RAG优化 #RecursiveCharacterTextSplitter

相关笔记