群聊变语料:线程重建与统一 chunk
上一篇把文档这条路走通了:解析、按标题层级切、重叠窗口、写进 pgvector。现在把第 8 篇导出的群聊接上来。
直觉上这应该很简单——都是文本,套同一套切分就行。这一篇会用三次失败说明为什么不行。
坏结果(一):蒸馏成问答对
第一版的做法是人工蒸馏。读完 618 条消息,审出「问题—解法」对,每条绑定证据消息 ID,然后只对这些问答对做检索。
审出来 59 条。验收集上 Recall@3 = 100%。
看起来非常好,直到你注意到验收集只有 15 条查询——而且是照着这 59 条 QA 出的。真实提问一来,长尾全部落空:
查询:安装包双击没反应
命中:无(阈值以下)
实际:群里 2026-05 讨论过,但那次没形成明确结论,审核时被判为「未闭环」,没进 QA
问题不在审核标准,在于语料规模被人工审核的产出速度卡死了。618 条消息里能提炼出闭环结论的就那么多;加一个群,就要再审一遍。这条路的天花板是人力,不是数据。
所以放弃蒸馏。语料单元是会话线程片段本身,检索的是原始讨论——包括那些没有结论的。「群里问过但没解决」也是有用的信息,第 12 篇会用到它。
坏结果(二):逐条消息 embedding
那就别蒸馏,直接把 618 条消息每条做一个 chunk。
查询:窗口不显示
Top-1 「+1 我也是」 0.62
Top-2 「有人遇到过吗」 0.58
Top-3 「窗口不见了」 0.57
三条全是废话,而真正的解法在第 4 条之后。
原因很直白:一条群聊消息脱离上下文没有语义。 「+1 我也是」在向量空间里和任何抱怨都相似,因为它本身不含信息,embedding 只能抓到它的语气。463 条文本消息里,这类附和、确认、追问占了相当比例。
坏结果(三):套用上一篇的固定窗口
那就把消息按时间拼成一长串,用第 9 篇那套 500 字符窗口 + 重叠切。
chunk 12 …14:02 张 我这边点图标没反应
14:02 张 任务管理器里有进程
14:03 李 你什么版本
14:05 张 3.1.4
14:06 李 我看看
chunk 13 14:31 王 今天下午的会改到几点
14:33 李 三点半
14:47 李 @张 找到了,3.1.4 渲染进程起不来
14:47 李 先结束残留进程再清缓存,只清缓存会复发
检索「点图标没反应」命中 chunk 12——提问在里面,结论在 chunk 13。而 chunk 13 里还混进了一段完全无关的会议时间讨论。
重叠窗口在这里救不了:这两段之间隔了 25 分钟和两条无关消息,把窗口开到能覆盖它们,就会顺手吞进更多噪声。
同一段群聊,固定窗口切出的边界与线程重建切出的边界
群聊不是文章。 文章的语义边界和物理顺序基本一致,所以按位置切是有效的;群聊是多个话题在同一条时间轴上交错,物理相邻的两条消息可能毫无关系,而相隔 25 分钟的两条才是一问一答。
线程重建
切分的单位应该是一次完整的问答讨论,而不是一段固定长度的文本。分两步做。
第一步:合并连续发言
同一个人连着说的几句话,几乎总是一个意思被拆成了几条。先合并:
def merge_utterances(messages, *, gap=timedelta(seconds=90)):
units, current = [], None
for msg in messages: # 已按 sentAt 升序
same_speaker = current and current.sender_ref == msg.sender_ref
close_enough = current and msg.sent_at - current.ended_at <= gap
if same_speaker and close_enough:
current.append(msg)
else:
current = Utterance.start(msg)
units.append(current)
return units
90 秒是打字节奏的量级。这一步能把 618 条消息压到 300 出头个发言单元,后面所有判断都在发言单元上做。
第二步:把发言单元聚成线程
用四个信号,强弱有别:
def assign_thread(unit, open_threads):
# 1. 显式引用:最强信号,直接归属
if unit.quoted_message_id:
owner = find_thread_containing(open_threads, unit.quoted_message_id)
if owner:
return owner
# 2. @提及:归到被提及者最近参与且仍活跃的线程
for ref in unit.mentioned_refs:
owner = latest_active_thread_involving(open_threads, ref)
if owner:
return owner
# 3. 问句且与所有活跃线程都不相似:开一条新线程
if unit.looks_like_question and max_similarity(unit, open_threads) < 0.45:
return None
# 4. 兜底:并入最近活跃且词面重合最高的线程
return best_lexical_match(open_threads, unit, min_overlap=2)
线程在空闲超过 idle_gap(默认 30 分钟)后关闭,且总跨度不超过 max_span(默认 6 小时)——群里的话题很少真的连续讨论半天,跨度过大通常意味着聚错了。
这套规则不完美,也不打算做到完美。 实际抽查大约有一成的发言单元会被归错线程——大多是没有 @、没有引用、只说一句「好了」的确认消息。评估方式是人工标注 50 条线程边界当作基准集,报告边界准确率,改规则时看这个数字有没有变好。第 4 篇建立的纪律在这里同样适用:不能凭感觉说「这版切得更好了」。
噪声:留在正文里,不进 embedding
「+1」「我也是」「收到」这类消息,删掉会破坏线程的完整性(它们能说明这个问题影响面多大),留着又会稀释向量。
分开处理:
NOISE = re.compile(r'^\s*(\+1|同问|我也是|收到|好的?|谢谢?|辛苦 了?|[。.!!~]*)\s*$')
def build_chunk(thread):
body = render_transcript(thread.units) # 全文,人看的
signal = [u for u in thread.units if not NOISE.match(u.text)]
return Chunk(
text=body,
embedding_input=render_transcript(signal), # 机器看的
evidence_ids=[m.message_id for u in thread.units for m in u.messages],
)
正文和 embedding 输入是两个字段。 用户看到的引用是完整对话,向量算的是去噪后的内容。这个分离在第 12 篇展示引用时还会用到。
结论常常不自足
线程重建最实际的收益在这里。群里的结论往往长这样:
这两句单独拿出来毫无意义,但放在线程里,前面十条消息给了它全部语境。这正是 chunk 边界必须包住整条线程、而不是包住一个固定长度的理由。
没有结论的线程也要留下
def infer_resolution(thread) -> str:
if thread.has_confirmation_after_suggestion(): # 建议之后有肯定回复
return 'resolved'
if thread.has_suggestion(): # 有人给了做法,但没人确认
return 'workaround'
return 'open'
open 的线程照样入库、照样能被检索到,但不能当作答案用。第 12 篇会用这个字段生成一种特殊回答:「群里 2026-07 有人报过同样现象,当时没有结论。」
这比编一个解法强,也比直接说「没找到」有用。
统一 chunk 表
现在两个来源都能切了。它们进同一张表:
CREATE TABLE chunks (
id UUID PRIMARY KEY,
text TEXT NOT NULL, -- 展示用全文
embedding vector(1536) NOT NULL,
source_type TEXT NOT NULL, -- 'document' | 'chat'
source_ref TEXT NOT NULL, -- document_id | conversationRef
occurred_at TIMESTAMPTZ NOT NULL, -- 文档更新时间 | 线程最后一条消息时间
heading_path TEXT[], -- 文档源:标题层级,引用定位锚点
evidence_ids TEXT[], -- 群聊源:证据消息 ID
resolution_state TEXT, -- 群聊源:resolved | workaround | open
CONSTRAINT chunk_source_shape CHECK (
(source_type = 'document' AND heading_path IS NOT NULL AND resolution_state IS NULL)
OR (source_type = 'chat' AND evidence_ids IS NOT NULL AND resolution_state IS NOT NULL)
)
);
那个 CHECK 约束值得留意。两个来源共享大部分字段,但各有几个必填项,用约束把它写死,比在应用层记着强——第 9 篇写的文档入库代码不会知道第 10 篇加了 resolution_state。
为什么不分成两张表
分表的诱惑是真实的:字段更干净,各自演进互不影响。但检索时会付出代价。
混合召回要在同一个分数空间里比较。 分两张表意味着两次查询、两个 Top-K,然后你得决定「文档的 0.72 和群聊的 0.68 哪个更该排前面」——这个决定没有依据,因为两次检索的分数分布不同。合成一张表,一次 ORDER BY embedding <=> query 就把这个问题消掉了。
同理,向量维度和 embedding 模型必须完全一致。两个来源用不同模型算向量,余弦相似度之间没有可比性。
两个来源说法不一样的时候
这是双源语料真正棘手的地方,也是这一篇最后一个设计决定。
《升级说明》2.1 节 升级前必须先卸载旧版本
群聊 2026-07-14 3.2 之后不用卸载,直接覆盖安装即可;先卸载反而会丢配置
哪个对?文档是官方的,但可能滞后;群聊是实战验证的,但可能是特例,也可能已经过期。
服务端不做自动语义冲突检测。 判断两段中文是不是在说相反的事,本身就是一个不可靠的模型任务,而且它一旦判错,错误会被藏在系统内部,用户看不到。
做法是把分歧 显式交给模型呈现:
DUAL_SOURCE_RULE = """引用来自不同来源类型时:
- 分别说明「文档怎么写」和「群里怎么说」,各自标注时间
- 不要合并成一个说法,不要替用户选择哪个对
- 两者一致时正常合并引用即可,不需要特意区分"""
再加一条服务端强制的呈现规则:跨来源的引用必须分组展示,模型说什么都改不了这个结构。得到的回答是这样的:
官方《排查指南》4.3 节:清理缓存目录后重启。
群里 2026-07-14 的结论:要先结束残留进程再清缓存,只清缓存会复发。
两个说法都给你,按群里的顺序试更稳。
用户拿到的是两条可核对的信息和各自的时间,而不是一个被系统仲裁过、看不出来源的答案。让模型呈现,不让模型仲裁——和第 12 篇「拒答由服务端计算」是同一条纪律。
时间衰减只作用于群聊
def time_decay(chunk, now, half_life_days=180):
if chunk.source_type != 'chat':
return 1.0
age = (now - chunk.occurred_at).days
return 0.5 ** (age / half_life_days)
文档有显式的版本和更新时间,过期了应该重新上传而不是靠算法猜;群聊结论会自然老化——旧版本的解法还躺在群里,而且措辞和新问题一样贴切。半衰期取 180 天是个起点,第 11 篇会把它当成一个实验因子来调。
媒体的洞要老实标出来
第 8 篇留下的占位符在这里显形。一条线程里可能是这样:
张:这个报错看不懂
张:[图片]
李:这个是证书过期了
关键信息在图里,文本层什么都没有。不要猜,也不要把占位符当正文送去 embedding。 chunk 上记一个标记:
has_unresolved_media = any(u.media_placeholder for u in thread.units)
命中这类 chunk 时,回答里明确说明「这段讨论里有一张图,内容未纳入」。46 条图片和 59 条视频消息就是这么处理的——承认信息缺失,比假装完整强。
增量重建
第 8 篇的 delta.jsonl 在这里消费:
async def apply_delta(delta_path: Path):
affected: set[str] = set()
async for op in read_jsonl(delta_path):
if op['operation'] == 'upsert':
await messages.upsert(op['item'])
affected.add(op['item']['conversationRef'])
elif op['operation'] == 'recalled':
await messages.mark_recalled(op['messageId'])
affected.add(op['item']['conversationRef'])
elif op['operation'] == 'missing':
await messages.flag_for_review(op['messageId']) # 不删
for conversation_ref in affected:
await rebuild_threads_for(conversation_ref) # 整会话重切
两个决定:
missing 只标记待确认,不删。 理由在第 8 篇讲过——本地缓存一次正常清理,就会把一批有效知识抹掉。
受影响的会话整个重切,不做局部更新。 一条消息变化可能让线程边界整体移动(比如补进来一条 @ 消息,两条线程该合并了)。整会话重切是分钟级操作,为它写增量线程合并的复杂度不值得。
测试与验收
docker compose up -d postgres redis
uv run pytest tests/chat/test_thread_rebuild.py -q
uv run pytest tests/chat/test_dual_source.py -q
uv run python -m scripts.ingest_chat fixtures/export-bundle
fixtures/export-bundle/ 里的合成语料刻意包含了本篇每一个坏结果:交错话题、25 分钟后才出现的结论、大量「+1」、没有结论的线程、只有图片占位符的讨论,以及一对和文档说法冲突的群聊结论。
验收标准:
- 线程边界准确率在 50 条标注基准上不低于 0.85。
- 检索「点图标没反应」,命中的 chunk 里同时包含提问和结论。
- 「+1 我也是」不再出现在任何查询的 Top-5。
- 同时命中文档与群聊时,回答分组展示两个来源并各自带时间。
resolution_state = 'open' 的 chunk 能被检索到,但不会被当作解法输出。
- 同一份导出包重复 ingest 两次,chunk 总数不变(幂等)。
- 修改一条消息后重跑,只有它所在会话的 chunk 被重建。
现在库里有两种来源的向量了。下一篇要证明:纯向量检索会漏掉哪些答案——尤其是用户直接贴错误码和版本号的时候,然后用全文检索、查询改写和重排把它们捞回来。