适用读者:正在做文档问答(RAG)的开发者。 关键词:RAG、多轮对话、Query Rewriting、指代消解、语义向量、Embedding、chunk 切分 配套仓库:本文所有改动均来自一次真实的 git 提交(多轮查询改写 + 云端语义向量 + txt 章节切分)。
0. TL;DR
单轮 RAG 很容易跑通,但一上多轮追问 和语义检索就各种翻车。这次我们给自研的本地文档问答系统做了三件事:
- 多轮查询改写(Query Rewriting):检索前先用 LLM 把含"他 / 这个"的追问结合历史补全成独立问句,修复指代翻车。
- 云端语义向量 :把只数字面词的本地哈希向量器,换成通义
text-embedding-v3,让同义词 / 改写真的能被召回。 - TXT 章节切分:纯文本也能识别"序章 / 第一章"作为标题,溯源能落到具体章节。
下面把根因、解法和落地代码一次讲清。
1. 痛点一:多轮追问里"他 / 这个"翻车
1.1 一个真实翻车现场
测试文档是一篇跨世界观同人小说,对话如下:
arduino
用户:为什么佐助什么事都没有做?
AI: (正确,引用了"佐助只在专有名词清单中"的片段)
用户:那么他厉害吗?
AI: 根据文档片段,无法判断"他"具体指谁......
文档中并未对任何角色的实力做出统一的"厉害"评价......
第二轮"他"指代上一轮的"佐助",但系统完全没接住,反而捞回了"缝合 BOSS 极其强大"之类的片段,答成了"不知道他指谁"。
1.2 根因:不是答错,是捞错
RAG 的本质只有两步:
- 先在文档里找相关的几段文字(检索);
- 把这几段文字交给大模型,让它看着文字回答。
大模型只会回答"它手里拿到的那几段文字"里有的内容。翻车链路是:
- 多轮追问被当成字面词去搜:"他"在文档里从没出现,检索直接扑空。
- 历史在消息里,但兜不住 :代码虽把历史塞进发给大模型的消息,但系统提示强制"答案必须引用下方片段、片段没有就说明未找到"。片段里没佐助,历史看到了也没法用来作答------检索一旦捞错,历史也救不了场。
一句话:多轮对话里的代词(他 / 它 / 这个)必须先在搜索前换成真名字,否则一定捞错资料。
1.3 解法:检索前先做「查询改写」
这是生产级多轮 RAG 的通用范式(LlamaIndex 叫 CondenseQuestionChatEngine,Dify 叫"多轮问题补全")。核心思路是不写死任何规则,而是把"历史 + 当前这句不完整的话"交给 LLM,让它改写成一句独立、完整、不含代词的检索问句:
arduino
原问句: "那么他厉害吗?"
历史: "为什么佐助什么事都没有做?" + 上轮回答
改写输出: "宇智波佐助在文档故事中厉害吗?" ← 自动把"他"补全成"佐助"
1.4 落地代码
新增改写函数(节选自 server/app/services/rag.py):
python
_REWRITE_SYSTEM = (
"你是一个问答系统的「检索问句改写器」。用户正在基于文档进行多轮对话。"
"下面给出最近的对话历史与用户的最新提问。最新提问里可能含有需要结合上下文才能理解的"
"代词(他/它/这个/那个)或省略。请只做一件事:把最新提问结合历史,改写成一句"
"独立、完整、不含歧义的检索问句,用于去知识库检索。"
"规则:\n"
"1. 只输出改写后的一句话,不要回答、不要解释、不要加任何前缀;\n"
"2. 必须保留用户原意;\n"
"3. 若最新提问本身已独立完整,则原样返回。"
)
def _rewrite_query(question, history):
"""多轮补全:把含指代/省略的追问结合历史改写成独立检索问句。
无历史时直接返回原问句(不额外消耗 LLM 调用)。
改写失败则回退到「历史+原问」拼接,保证检索仍有上下文。
"""
if not history:
return question
recent = history[-6:]
hist_text = "\n".join(
f"{'用户' if m['role'] == 'user' else '助手'}:{m['content']}" for m in recent
)
messages = [
{"role": "system", "content": _REWRITE_SYSTEM},
{"role": "user", "content": (
f"对话历史:\n{hist_text}\n\n最新提问:\n{question}"
f"\n\n请输出改写后的独立检索问句:"
)},
]
try:
llm = get_llm()
out = llm.chat(messages, temperature=0.0, stream=False)
return out.strip() or question
except Exception:
# 改写失败不阻塞主流程,回退到旧的拼接方式
ctx = "\n".join(f"{m['role']}: {m['content']}" for m in recent)
return f"{ctx}\n{question}"
在 answer_stream 里用改写后的问句去检索、并用它拼 prompt,保证"检索问句"和"发给大模型的问题"一致:
python
clean_history = _to_history(history)
# 多轮补全(.env 里 QUERY_REWRITE=off 可关闭,退回单轮 RAG,省一次 LLM 调用)
retrieval_query = _rewrite_query(question, clean_history) if QUERY_REWRITE else question
candidates = search(retrieval_query, top_k * 4 or top_k, threshold, document_ids=document_ids)
candidates = _heuristic_rerank(retrieval_query, candidates)[:top_k]
prompt = build_prompt(retrieval_query, contexts, low_confidence=low_confidence)
开关(server/app/config.py):
python
# on=开启:追问结合历史补全为独立问句后再检索(生产级多轮 RAG 标配,对应 Dify 的"多轮问题补全")
# off=关闭:追问不做补全,退回单轮 RAG(省一次 LLM 调用,但"他/这个"等代词会捞错资料)
QUERY_REWRITE = _get("QUERY_REWRITE", "on").lower() in ("on", "1", "true", "yes")
1.5 把"实际检索问句"透出到前端
answer_stream 额外返回 retrieval_query;chat.py 的 SSE done 事件带上它;前端调试面板(LlmDebugPanel.vue)在顶部用醒目标签展示:
🟠 实际检索问句:
宇智波佐助在文档故事中厉害吗
这样就能直观看到"他"被补全成了什么,验证改写是否生效。
1.6 与 Dify 的对比
结论:逻辑完全一致,都是"检索前用 LLM 把追问补全成独立问句",不是自创技巧。
| 维度 | 本文实现(手撸项目) | Dify |
|---|---|---|
| 核心技术 | LLM 查询改写 | 同名能力("多轮问题补全") |
| 是否写死规则 | 否,通用改写 | 否,通用改写 |
| 开关 | .env 里 QUERY_REWRITE=on/off |
知识库检索设置里的 UI 开关 |
| 额外 LLM 调用 | 有 1 次(改写),失败自动回退 | 同理,平台内部封装 |
| 调试可见性 | 前端调试面板直接展示"实际检索问句" | 平台有日志 / 调试视图 |
| 代价 | 自己维护代码 | 引入一整套低代码平台 |
本质区别不在"算法",而在交付形态:Dify 是把能力做成开箱即用的平台 + UI 开关;本文是在自家代码里手写十几行。效果等价,取舍在维护成本与掌控力。
2. 痛点二:本地哈希向量器"不懂意思"
查询改写解决了"问句补全",但还有个更底层的问题:检索本身靠的向量器如果只数字面词,改写也救不了同义词召回。
2.1 原来的哈希向量器长这样
server/app/services/embedder.py 里的 HashingEmbedder:
python
def embed(self, text):
counts = {}
for t in _tokenize(text or ""):
counts[t] = counts.get(t, 0) + 1
vec = [0.0] * self.dim # 1024 维
for t, c in counts.items():
idx = int(hashlib.md5(t.encode("utf-8")).hexdigest(), 16) % self.dim
vec[idx] += 1.0 + math.log(c) # 词频越高权重越大
norm = math.sqrt(sum(v * v for v in vec))
return [v / norm for v in vec] if norm > 0 else list(vec)
逻辑是:把文本拆成字 / 词(中文还拆成相邻两字组合),用 md5 随机分到 1024 个格子,按词频累加,最后 L2 归一化。
它只数字面出现了哪些字,并不懂语义。实测暴露四个硬伤:
- 无关文本基线高达 0.37:哈希只有正权重、没有符号,碰撞只会单向累加正分,系统性抬高所有相似度。语义完全无关的两段中文,余弦相似度都能到 0.37,比"真相关 query"的分数(0.12~0.21)还高------根本分不清相关和碰巧撞字。
- 虚词主导方向:单字 token 贡献了向量模长的 55.9%,权重最高的全是"的 / 一 / 是"这类虚词,是实词的 4 倍多。所以"同语言任意两段文本天然相似"。
- 零泛化:搜"机器猫"能匹配"猫型机器人"只是因为共享单字"猫";换成"哆啦 A 梦"(字面完全不同)直接掉到随机水平。同义词、改写、跨语言一律失效。
- 短 query 被长 chunk 稀释 :相关 / 无关的边界几乎重合,阈值
0.12几乎没有安全边际。
2.2 解法:换云端语义向量
新增 CloudEmbedder,走 OpenAI 兼容协议(通义 text-embedding-v3 原生支持),复用项目已有的 openai 库:
python
class CloudEmbedder:
# 通义 text-embedding-v3 限制单次批量 ≤ 10 条,超出需分批
MAX_BATCH = 10
def __init__(self, base_url=EMBED_BASE_URL, api_key=EMBED_API_KEY,
model=EMBED_MODEL, dim=EMBED_DIM):
if not api_key:
raise RuntimeError("未配置 Embedding API Key(EMBED_API_KEY),请在 .env 配置后重试")
self.client = OpenAI(api_key=api_key, base_url=base_url, timeout=LLM_TIMEOUT)
self.model = model
self.dim = dim
def embed_batch(self, texts):
results = []
for i in range(0, len(texts), self.MAX_BATCH): # 自动按 ≤10 条分批
batch = texts[i : i + self.MAX_BATCH]
payload = {"model": self.model, "input": [t if t else " " for t in batch]}
if self.dim and self.dim > 0:
payload["dimensions"] = self.dim # 固定输出维度
try:
resp = self.client.embeddings.create(**payload)
except Exception as exc:
raise RuntimeError(friendly_error(exc)) from exc
ordered = sorted(resp.data, key=lambda d: d.index)
results.extend(_l2(item.embedding) for item in ordered)
return results
get_embedder() 按 EMBED_PROVIDER 切换:
python
def get_embedder():
if EMBED_PROVIDER == "cloud":
return CloudEmbedder()
return HashingEmbedder()
配置(server/app/config.py + .env):
python
EMBED_PROVIDER = _get("EMBED_PROVIDER", "hashing")
EMBED_DIM = int(_get("EMBED_DIM", "1024"))
EMBED_BASE_URL = _get("EMBED_BASE_URL", "https://dashscope.aliyuncs.com/compatible-mode/v1")
EMBED_API_KEY = _get("EMBED_API_KEY", "")
EMBED_MODEL = _get("EMBED_MODEL", "text-embedding-v3")
切到云端后,"机器猫"和"哆啦 A 梦"在语义向量里会靠得很近,搜一个能召回另一个;之前"无关相似度 0.37"的问题也消失,因为语义向量里无关内容会真正分开。
2.3 踩坑:批量大小上限
通义 text-embedding-v3 限制单次批量 ≤ 10 条。一开始把整篇文档的 11 个 chunk 一次性发过去,直接 400:
vbnet
Error code: 400 - ... batch size is invalid, it should not be larger than 10
修复就是上面 embed_batch 里的 MAX_BATCH = 10 自动分批。注意不同服务商的批量上限不同,换模型时这个值要跟着调。
2.4 切换方案后必须「重新索引」
旧 chunk 存的是哈希向量,新 query 是云端向量,两者不在同一语义空间,检索会失效。所以新增 reindex_all()(server/app/services/vectorstore.py)和重索引脚本 server/scripts/reindex.py:
python
def reindex_all():
"""用当前 embedder 重新生成所有 chunk 的向量并写回。"""
emb = get_embedder()
rows = get_all_chunks()
if not rows:
return 0
texts = [f"{r['heading']}\n{r['text']}" if r["heading"] else r["text"] for r in rows]
vecs = emb.embed_batch(texts)
for r, vec in zip(rows, vecs):
update_chunk_embedding(r["id"], json.dumps(vec))
return len(rows)
执行:
bash
cd server
python scripts/reindex.py
如果还想要章节标题 + 新切分 生效(见第 3 节),则应该删掉旧文档重新上传,而不是只跑 reindex------因为 reindex 只重算向量,不会重新切分。
2.5 阈值要跟着调
语义向量区分度比哈希高很多,旧阈值 SCORE_THRESHOLD=0.12 会把大量无关片段都召回。建议切到 cloud 后改成 0.3~0.4:
ini
SCORE_THRESHOLD=0.35
3. 痛点三:TXT 章节标题丢失
chunker.py 原来只对 .md / .pdf / .docx 保留"第几章"标题,.txt 走纯递归切分、标题恒为空。结果 6 个 chunk 的 heading 全是 "",检索时显示不出出自哪章,甚至一个 chunk 从「序章」中部切到「第二章」开头。
修复:给纯文本加一套章节行正则,让 heading 带上章节名,且跨章节不做 overlap(避免语义混淆):
python
# 纯文本章节行:序章/尾声/前言/附录,或「第X章/节/回/卷/部/篇」,或英文 Chapter N
_CHAPTER_RE = re.compile(
r"^\s*(?:序章|尾声|前言|后记|附录|第[一二三四五六七八九十百千零0-9]+[章节回卷部篇]|Chapter\s+\d+)\b"
)
def chunk_document(text, fmt, size=CHUNK_SIZE, overlap=CHUNK_OVERLAP):
if fmt == "txt":
items = _split_plaintext_by_heading(text, size) # 按章节行识别标题
elif fmt in _STRUCTURED_FORMATS:
items = _split_markdown_by_heading(text, size)
else:
items = [("", piece) for piece in _split_recursive(text, SEPARATORS, size)]
items = [(h, p) for h, p in items if p.strip()]
return _apply_overlap(items, overlap) # overlap 仅在同 heading 内施加
build_prompt 里章节名会拼进片段,大模型回答时也能带上章节上下文;前端溯源卡片同样显示"章节:第一章 ..."。
4. 工程细节与踩坑
4.1 前端删除会连向量一起删(别留脏数据)
向量没有独立的"向量数据库",它就是 chunks 表里的一列 embedding(JSON),检索时全表读出来在内存算余弦。删除文档靠 SQLite 外键级联:
python
# app/db/database.py
def get_conn():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON") # 关键:否则 ON DELETE CASCADE 不生效
return conn
# chunks 表定义里
# FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE
DELETE FROM documents 会级联删掉所有 chunk(含向量)并清理磁盘原文件,所以前端删除是干净的,没有孤儿向量。
4.2 调试可见性
除了"实际检索问句",chat.py 的 SSE done 事件还把实际发给大模型的内容(llm_messages)一并返回,前端调试面板可展开查看系统提示 / 历史 / 检索片段,方便定位"是捞错了还是答错了"。
4.3 已知瓶颈
接入云端语义向量后,全表拉取在内存算余弦仍是瓶颈:文档量大时每次检索都要把全部 chunk 读出来算一遍。当前实现已在文件头注释标明这一点,后续需改为带向量索引的方案(如 pgvector / Chroma)或至少把全部向量缓存到内存。这是"先跑通、再优化"的取舍,不是 bug。
5. 生产 checklist(这次补齐的能力)
| 项 | 状态 | 说明 |
|---|---|---|
| 多轮查询改写 | ✅ | .env 里 QUERY_REWRITE=on/off |
| 实际检索问句透出 | ✅ | 前端调试面板展示,验证改写效果 |
| 云端语义向量 | ✅ | EMBED_PROVIDER=cloud + 通义 text-embedding-v3 |
| 批量向量化自动分批 | ✅ | 通义限制 ≤10 条 / 批 |
| 切方案后重索引 | ✅ | python scripts/reindex.py |
| TXT 章节切分 | ✅ | 识别序章 / 第一章等,跨章节不做 overlap |
| 前端删除级联清理向量 | ✅ | ON DELETE CASCADE + PRAGMA foreign_keys |
| 向量索引(大规模检索) | ⏳ | 文档量大时需引入向量库或缓存 |
| Query Decomposition | ⏳ | "比较 A 和 B" 类复杂问可拆成多问各搜后合并 |
6. 一句话总结
多轮 RAG 翻车,根因几乎都在"检索捞错资料"。两招成本低、收益高:
- 检索前用 LLM 把追问补全成独立问句(Query Rewriting)------和 Dify、LlamaIndex 同源的通用范式;
- 用真正的语义向量替换哈希词袋------让同义词 / 改写能被捞回,把"无关相似度"从 0.37 压到接近 0。
加上"TXT 章节切分"和"删除级联清理"这些工程细节,你的本地 RAG 就追平了生产级文档问答的基本盘。