把本地 RAG 问答做"准":多轮指代消解 + 云端语义向量 + TXT 章节切分

适用读者:正在做文档问答(RAG)的开发者。 关键词:RAG、多轮对话、Query Rewriting、指代消解、语义向量、Embedding、chunk 切分 配套仓库:本文所有改动均来自一次真实的 git 提交(多轮查询改写 + 云端语义向量 + txt 章节切分)。


0. TL;DR

单轮 RAG 很容易跑通,但一上多轮追问 和语义检索就各种翻车。这次我们给自研的本地文档问答系统做了三件事:

  1. 多轮查询改写(Query Rewriting):检索前先用 LLM 把含"他 / 这个"的追问结合历史补全成独立问句,修复指代翻车。
  2. 云端语义向量 :把只数字面词的本地哈希向量器,换成通义 text-embedding-v3,让同义词 / 改写真的能被召回。
  3. TXT 章节切分:纯文本也能识别"序章 / 第一章"作为标题,溯源能落到具体章节。

下面把根因、解法和落地代码一次讲清。


1. 痛点一:多轮追问里"他 / 这个"翻车

1.1 一个真实翻车现场

测试文档是一篇跨世界观同人小说,对话如下:

arduino 复制代码
用户:为什么佐助什么事都没有做?
AI:  (正确,引用了"佐助只在专有名词清单中"的片段)

用户:那么他厉害吗?
AI:  根据文档片段,无法判断"他"具体指谁......
     文档中并未对任何角色的实力做出统一的"厉害"评价......

第二轮"他"指代上一轮的"佐助",但系统完全没接住,反而捞回了"缝合 BOSS 极其强大"之类的片段,答成了"不知道他指谁"。

1.2 根因:不是答错,是捞错

RAG 的本质只有两步:

  1. 先在文档里找相关的几段文字(检索);
  2. 把这几段文字交给大模型,让它看着文字回答。

大模型只会回答"它手里拿到的那几段文字"里有的内容。翻车链路是:

  • 多轮追问被当成字面词去搜:"他"在文档里从没出现,检索直接扑空。
  • 历史在消息里,但兜不住 :代码虽把历史塞进发给大模型的消息,但系统提示强制"答案必须引用下方片段、片段没有就说明未找到"。片段里没佐助,历史看到了也没法用来作答------检索一旦捞错,历史也救不了场。

一句话:多轮对话里的代词(他 / 它 / 这个)必须先在搜索前换成真名字,否则一定捞错资料。

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 归一化。

它只数字面出现了哪些字,并不懂语义。实测暴露四个硬伤:

  1. 无关文本基线高达 0.37:哈希只有正权重、没有符号,碰撞只会单向累加正分,系统性抬高所有相似度。语义完全无关的两段中文,余弦相似度都能到 0.37,比"真相关 query"的分数(0.12~0.21)还高------根本分不清相关和碰巧撞字。
  2. 虚词主导方向:单字 token 贡献了向量模长的 55.9%,权重最高的全是"的 / 一 / 是"这类虚词,是实词的 4 倍多。所以"同语言任意两段文本天然相似"。
  3. 零泛化:搜"机器猫"能匹配"猫型机器人"只是因为共享单字"猫";换成"哆啦 A 梦"(字面完全不同)直接掉到随机水平。同义词、改写、跨语言一律失效。
  4. 短 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 就追平了生产级文档问答的基本盘。

相关推荐
怕浪猫1 小时前
2026年9月面18个后端
后端·面试·github
IT_陈寒1 小时前
Vue 这个响应式陷阱,我的头发都掉没了
前端·人工智能·后端
谁在黄金彼岸2 小时前
trae的gitee mcp莫名错误
后端
前端的日常2 小时前
一个人+AI做情侣食谱小程序,30天纯赚
前端·javascript·后端
再吃一根胡萝卜2 小时前
给 RAG 问答加上「多轮指代消解」:让"他 / 这个"不再翻车
后端
Wx-bishekaifayuan3 小时前
springboot生活易购超市管理系统54086-计算机课程设计、毕业设计
spring boot·后端·python·django·课程设计·express·旅游
lizhongxuan3 小时前
VirtIO:虚拟机的磁盘、网络与内存是怎样工作的
后端
计算机毕设定制辅导-无忧学长3 小时前
《基于Spring Boot的减脂轻食购物网站》
java·vue.js·spring boot·后端·减脂轻食购物网站
沐欣工作室_lvyiyi3 小时前
基于SpringBoot的博物馆预约系统设计与实现(论文+源码)
java·spring boot·后端·计算机毕业设计