基于混合语义溯源的法律文档摘要可追溯系统设计与实现
基于混合语义溯源的法律文档摘要可追溯系统设计与实现
核心技术: LLM Prompt标注 + OpenAI Embedding余弦相似度 = 双通道融合溯源
论文灵感: ALCE (ACL 2023) + RARR (EMNLP 2023)
开发环境: Python 3.11 + FastAPI + Vue 3 + OpenAI API
一、前言:为什么法律文档摘要需要"溯源"?
在法律AI应用中,“幻觉”(Hallucination) 是最致命的问题。当AI摘要系统输出一句"法院认定合同有效",律师和法官需要立即验证——这句话在原文的哪个位置? 如果无法溯源,AI生成的摘要就毫无法律应用价值。
传统摘要系统的痛点:
- 信任危机 — 生成的要点没有引证,用户不敢采信
- 效率低下 — 用户需要手动在几十页原文中搜索对应段落
- 责任风险 — 错误引用导致的法律后果无法追责
本文的解决方案
我们设计了一套混合溯源架构:每个摘要要点自动关联到原文的精确段落,用户点击即可跳转高亮。
二、技术方案对比与选型
| 方案 | 论文来源 | 核心思路 | 优势 | 劣势 | GPU需求 |
|---|---|---|---|---|---|
| NLI溯源 | ALCE, ACL 2023 | 用NLI模型验证摘要-原文蕴含关系 | 精确度最高 | 需要NLI分类器 | 是 |
| Cross-Encoder | RARR, EMNLP 2023 | 交叉编码器对摘要-原文对打分 | 高召回率 | 计算量大 | 是 |
| Attention归因 | — | 利用Transformer注意力矩阵定位来源 | 可解释性强 | 仅限白盒模型 | 是 |
| 混合溯源(本文) | 综合ALCE+RARR | LLM标注 + Embedding相似度 | 无GPU, API友好 | 依赖Embedding质量 | 否 |
最终选择:混合溯源(双通道融合)— 兼顾精确度和工程可行性,纯API方案无需GPU。
三、系统架构
┌─────────────────────┐
│ 用户上传PDF │
└────────┬────────────┘
▼
┌─────────────────────┐
│ 文档分块 + 编号 │
│ [Block 0] [Block 1] │
└────────┬────────────┘
▼
┌──────────────┼──────────────┐
▼ ▼ ▼
┌────────────┐ ┌────────────┐ ┌────────────┐
│ RAG检索 │ │ LLM摘要 │ │ Embedding │
│ (ChromaDB) │ │ (GPT-4) │ │ (3-small) │
└──────┬─────┘ └──────┬─────┘ └──────┬─────┘
│ │ │
└───────────────┼───────────────┘
▼
┌──────────────────────┐
│ LLM标注 [来源: X,Y] │ ← 通道1
└──────────┬───────────┘
▼
┌──────────────────────┐
│ 语义cosine匹配 │ ← 通道2
└──────────┬───────────┘
▼
┌──────────────────────┐
│ 双通道融合 │
│ → source_mappings │
└──────────┬───────────┘
▼
┌──────────────────────┐
│ 前端点击跳转 + 高亮 │
└──────────────────────┘
四、核心算法详解
4.1 文档分块编号
将原文拆分为编号块,嵌入LLM Prompt中:
def _build_numbered_blocks(doc_id: str, max_blocks: int = 60) -> str:
"""构建带编号的原文块文本"""
blocks = get_blocks_by_doc(doc_id)
if not blocks:
return ""
lines = []
for i, b in enumerate(blocks[:max_blocks]):
content = b['content'] if isinstance(b, dict) else b.content
lines.append(f"[Block {i}] {content}")
return "\n".join(lines)
设计考量:
max_blocks=60控制上下文长度,避免超出LLM窗口- 编号从0开始,与数据库索引对齐
4.2 通道1:LLM Prompt 标注
在角色提示词中加入来源标注指令:
_SOURCE_INSTRUCTION = """
## 来源标注要求
在"关键要素"部分,每个要点后面用方括号标注来源原文块编号,
格式为 [来源: X] 或 [来源: X, Y],其中 X、Y 为原文块编号(从0开始)。
例如:
- 原告要求被告赔偿损失50万元 [来源: 3, 5]
- 法院认定合同有效 [来源: 12]
"""
解析LLM输出中的来源标注:
def _parse_llm_source_mappings(key_points: list[str]) -> tuple[list[str], list[dict]]:
"""从 LLM 输出的 [来源: X, Y] 标注中提取映射"""
clean_points = []
mappings = []
pattern = re.compile(r'\[来源:\s*([\d,\s]+)\]')
for i, point in enumerate(key_points):
m = pattern.search(point)
block_indices = []
if m:
nums = m.group(1).split(',')
block_indices = [int(n.strip()) for n in nums if n.strip().isdigit()]
clean_text = pattern.sub('', point).strip()
else:
clean_text = point.strip()
clean_points.append(clean_text)
if block_indices:
mappings.append({"point_index": i, "block_indices": block_indices})
return clean_points, mappings
局限性:LLM标注不总是准确——有时会标注错误的块编号,或者完全不标注。这就是为什么需要第二通道。
4.3 通道2:语义相似度匹配
这是系统的核心——用 OpenAI Embedding 计算每个摘要要点与所有原文块的余弦相似度:
def _get_embeddings(texts: list[str], client: OpenAI = None) -> list[list[float]]:
"""批量获取 OpenAI embedding,带缓存"""
if client is None:
client = _get_client()
uncached = [(i, t) for i, t in enumerate(texts) if t not in _embedding_cache]
if uncached:
batch_texts = [t for _, t in uncached]
for start in range(0, len(batch_texts), 100):
batch = batch_texts[start:start+100]
try:
resp = client.embeddings.create(
input=batch,
model="text-embedding-3-small"
)
for j, emb_data in enumerate(resp.data):
_embedding_cache[batch[j]] = emb_data.embedding
except Exception:
for t in batch:
_embedding_cache[t] = []
return [_embedding_cache.get(t, []) for t in texts]
def _cosine_similarity(a: list[float], b: list[float]) -> float:
"""余弦相似度 = dot(a,b) / (||a|| * ||b||)"""
if not a or not b:
return 0.0
a_arr = np.array(a)
b_arr = np.array(b)
dot = np.dot(a_arr, b_arr)
norm_a = np.linalg.norm(a_arr)
norm_b = np.linalg.norm(b_arr)
if norm_a == 0 or norm_b == 0:
return 0.0
return float(dot / (norm_a * norm_b))
关键参数:
- 模型:
text-embedding-3-small(1536维,OpenAI最新嵌入模型) - 批处理:每批100条,避免API限流
- 缓存:
dict级内存缓存,避免重复请求
语义匹配核心逻辑:
def _semantic_source_mapping(
key_points: list[str],
doc_id: str,
client: OpenAI = None,
top_k: int = 3,
threshold: float = 0.45,
) -> list[dict]:
"""
语义溯源:计算每个关键要素与原文块的 cosine similarity
取 similarity > threshold 的 top_k 个块作为来源
"""
blocks = get_blocks_by_doc(doc_id)
if not blocks or not key_points:
return []
block_texts = [b['content'] if isinstance(b, dict) else b.content for b in blocks]
# 批量获取所有 embeddings(要点 + 原文块)
all_texts = key_points + block_texts
all_embeddings = _get_embeddings(all_texts, client)
point_embeddings = all_embeddings[:len(key_points)]
block_embeddings = all_embeddings[len(key_points):]
mappings = []
for i, point_emb in enumerate(point_embeddings):
if not point_emb:
continue
# 计算与所有块的相似度
similarities = [(j, _cosine_similarity(point_emb, block_emb))
for j, block_emb in enumerate(block_embeddings) if block_emb]
# 按相似度降序排列,取 top_k 且 > threshold
similarities.sort(key=lambda x: x[1], reverse=True)
block_indices = [idx for idx, sim in similarities[:top_k] if sim >= threshold]
if block_indices:
mappings.append({
"point_index": i,
"block_indices": block_indices,
"scores": [round(sim, 4) for idx, sim in similarities[:len(block_indices)]],
})
return mappings
为什么 threshold=0.45?
- 法律文本中,摘要要点和原文块的语义不是完全重叠(摘要是概括性表述)
- 经实测,0.45能保证召回率的同时过滤明显不相关的段落
- 太高(>0.7)会漏掉间接引用的段落
- 太低(<0.3)会引入噪声
4.4 双通道融合
最后,将两个通道的结果合并:
def _merge_source_mappings(llm_mappings: list[dict], semantic_mappings: list[dict]) -> list[dict]:
"""
融合策略:
1. 语义匹配结果为基础
2. LLM标注作为高置信度补充(覆盖语义结果)
3. 去重 + 上限5个来源/要点
4. 标记融合方法:hybrid/llm/semantic
"""
result_map: dict[int, dict] = {}
# 先添加语义匹配的结果
for m in semantic_mappings:
pi = m["point_index"]
result_map[pi] = {
"point_index": pi,
"block_indices": m["block_indices"],
"method": "semantic",
}
# LLM 标注覆盖语义匹配
for m in llm_mappings:
pi = m["point_index"]
if pi in result_map:
combined = list(m["block_indices"])
for idx in result_map[pi]["block_indices"]:
if idx not in combined:
combined.append(idx)
result_map[pi] = {
"point_index": pi,
"block_indices": combined[:5],
"method": "hybrid",
}
else:
result_map[pi] = {
"point_index": pi,
"block_indices": m["block_indices"],
"method": "llm",
}
return sorted(result_map.values(), key=lambda x: x["point_index"])
五、前端交互实现
5.1 Vue 组件关键代码
SummaryPanel.vue — 溯源标签 + 点击事件:
<li v-for="(point, i) in summary.key_points" :key="i"
:class="{ 'has-source': !!getSourceMappingForPoint(i) }"
@click="handlePointClick(i)">
{{ point }}
<span v-if="getSourceMappingForPoint(i)" class="source-tag"
:class="getSourceMethod(i) || ''">
{{ getSourceMethod(i) === 'hybrid' ? '混合溯源' :
getSourceMethod(i) === 'semantic' ? '语义溯源' : '溯源' }}
</span>
</li>
DocumentView.vue — 自动跳转 + 高亮动画:
function handleLocateSource(blockIndices: number[]) {
// 1. 切换到原文块 tab
activeTab.value = 'blocks'
// 2. 高亮目标块
highlightedBlocks.value = new Set(blockIndices)
// 3. 滚动到第一个目标块
nextTick(() => {
const el = document.getElementById(`block-${blockIndices[0]}`)
el?.scrollIntoView({ behavior: 'smooth', block: 'center' })
})
// 4. 4秒后清除高亮
setTimeout(() => { highlightedBlocks.value.clear() }, 4000)
}
5.2 高亮动画 CSS
.highlighted {
border-left: 3px solid var(--accent);
background: var(--accent-light);
animation: pulse 1.5s ease-in-out;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
六、性能分析
| 阶段 | 时间开销 | 说明 |
|---|---|---|
| 文档分块编号 | <10ms | 纯字符串操作 |
| LLM摘要生成 | 3-8s | 取决于文档长度和模型 |
| Embedding计算 | 0.3-1s | API调用,60 blocks一批 |
| 余弦相似度矩阵 | <10ms | NumPy向量运算 |
| 融合 | <1ms | 简单字典操作 |
| 总增量延迟 | 约0.5-1.5s | 相比纯LLM摘要 |
七、常见问题
Q1: 什么情况下语义通道优于LLM通道?
A: 当LLM忘记标注来源(约30-40%的概率),语义通道能兜底。当LLM标注了错误的块号,语义通道的结果可以纠正。
Q2: 阈值0.45如何调优?
A: 可以根据具体场景调整。学术论文类文档(措辞更精确)可提高到0.55;合同类文档(重复条款多)可降低到0.35。
Q3: Embedding缓存有多大?
A: 1536维 × 4字节 × 60块 ≈ 360KB/文档。内存级缓存,重启清空。生产环境可改用Redis持久化。
八、总结与优化方向
本文贡献
- 提出了一种无GPU、纯API的法律文档溯源方案
- 双通道融合设计兼顾了精确度和工程可行性
- 前端"点击即溯源"的交互大幅提升了用户信任
未来优化
- 本地Embedding模型 — 使用 text2vec-large-chinese 替代 OpenAI API,降低成本
- 增量索引 — 新增文档时无需重新计算所有Embedding
- NLI验证层 — 对语义匹配结果再过一层NLI分类器,精确度可进一步提升
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)