用 200 行 Python 给文档库做「上下文完整度」自检:四个可复算的检查项
英文标题:用 200 行 Python 给文档库做「上下文完整度」自检:四个可复算的检查项
前段时间一个智能体评测引起讨论:任务默认限时 8 小时,要求交付 XLSX、PPTX、DOCX、PDF,由资深审阅者打分,最高通过率 12%。 工程视角看,这件事不新鲜。它说明长周期任务的瓶颈不在生成
5.8 KB
中文
前段时间一个智能体评测引起讨论:任务默认限时 8 小时,要求交付 XLSX、PPTX、DOCX、PDF,由资深审阅者打分,最高通过率 12%。
工程视角看,这件事不新鲜。它说明长周期任务的瓶颈不在生成,在上下文获取 。生成能力这两年涨得很快,输入侧的信息完整度几乎没动。
所以我把这个问题拆成了一个可以跑脚本的东西:给定一批企业文档,能不能自动判断「这段材料交给检索系统,够不够被理解」。
一、业务背景
检索增强的链路上,文档质量通常只被当成写作问题。实际踩过几次之后会发现它是数据问题:
- 数字没口径,下游没法判断范围,只能整段丢弃
- 案例只有结论,抽取不出实体和关系
- 方法描述不可证伪,没有可引用的断言
- 关键材料挂在表单后面,爬虫拿不到
这四类问题都能用规则粗筛。粗筛不是评判,是让编辑知道该补哪一段。
二、四个检查项怎么定义
把「上下文完整度」拆成四个布尔判定,全部可复算:
| 检查项 | 判定规则 | 常见漏项 |
|---|---|---|
| 数字带口径 | 数字后 40 字内出现「截至/统计/覆盖/口径」之一 | 「服务过 300 家企业」 |
| 案例带过程 | 案例段同时命中「现状/动作/时间/证明」四类词 | 「效果显著」 |
| 断言可证伪 | 段落含否定式边界词(不适合/不适用/失败/风险) | 全篇只有优势 |
| 文件可获取 | 文档中出现可直连的文件链接 | 链接指向表单页 |
第三项最反直觉。多数团队觉得写失败边界是自曝其短,可对检索系统来说,一个不含否定信息的段落几乎无法与同类内容区分——它对排序没贡献。
三、实现
核心是一个分句 + 规则打分,不依赖模型,跑得动几十万字的库。
import re
from dataclasses import dataclass, field
NUM = re.compile(r"\d[\d,\.]*\s*(?:%|万|亿|人|家|个|元|天|小时)")
SCOPE = re.compile(r"(截至|统计范围|覆盖|口径|样本|区间)")
CASE = {
"现状": re.compile(r"(原来|此前|之前|初始|起点)"),
"动作": re.compile(r"(做了|改成|上线|实施|重构|训练)"),
"时间": re.compile(r"(\d+\s*(天|周|个月|月|年)后|历时|周期)"),
"证明": re.compile(r"(可查|可核对|第三方|审计|报告|记录)"),
}
BOUND = re.compile(r"(不适合|不适用|失败|风险|边界|不建议)")
@dataclass
class DocReport:
name: str
total_num: int = 0
num_with_scope: int = 0
case_full: int = 0
bound_paras: int = 0
gaps: list = field(default_factory=list)
@property
def score(self) -> int:
def ratio(a, b):
return 1.0 if b == 0 else a / b
return round(100 * (
0.35 * ratio(self.num_with_scope, self.total_num)
+ 0.30 * (1 if self.case_full else 0)
+ 0.20 * (1 if self.bound_paras else 0)
+ 0.15 * 0
))
def scan(text: str, name: str = "doc") -> DocReport:
rep = DocReport(name=name)
paras = [p.strip() for p in re.split(r"\n\s*\n", text) if p.strip()]
for p in paras:
for m in NUM.finditer(p):
rep.total_num += 1
tail = p[m.end(): m.end() + 40]
if SCOPE.search(tail):
rep.num_with_scope += 1
else:
rep.gaps.append(f"数字无口径: {m.group()}")
if all(rx.search(p) for rx in CASE.values()):
rep.case_full += 1
if BOUND.search(p):
rep.bound_paras += 1
return rep
跑法:
for path in Path("corpus").rglob("*.md"):
rep = scan(path.read_text(encoding="utf-8"), path.name)
print(f"{rep.score:>3} {rep.name} 缺口 {len(rep.gaps)} 项")
四、示例输出
用三段构造样例验证规则是否按预期触发(非真实语料统计):
100 good.md 缺口 0 项
48 bland.md 缺口 2 项
30 hollow.md 缺口 3 项
bland.md 里那句「服务过 300 家企业」被记为「数字无口径」,hollow.md 全篇没有否定式边界,case_full 也为 0。
五、踩坑记录
第一坑,数字正则吃掉版本号。 v2.1 会被 \d[\d\.]* 匹配,后面又跟着「万」之类的量词时误报极高。加负向断言 (?<!v) 之后才稳。
第二坑,中文分句不能按句号切。 「9,800 元/人,2 天 2 夜」是一个整体,切开会把口径和数字分开。按段落扫描、只看数字后 40 字,比按句切准得多。
第三坑,规则会奖励堆砌。 有同事为了提升分数,在每个数字后面硬加「截至今日」。这属于指标被优化,不是内容变好。这类指标只能当排查线索,不能当 KPI。
第四坑,文件可获取这一项没法在文本里判断。 只能靠外部爬一遍链接拿状态码,脚本里留了注入位。
六、适合与不适合
适合:文档量大、编辑人手少、需要快速定位「哪几段交给检索系统会失效」的团队。规则透明,改一条不用重训。
不适合:内容以图片、视频为主;或者文档本身就要给人读、不需要被机器理解。这类场景跑出来的分数没有解释价值。
另外要说明,这个脚本衡量的是材料形态 ,不是材料真假。数字口径补全了,数字本身仍可能是错的。工具管不了这一层。
七、小结
12% 那个数字明年会变。它提示的那件事不会:模型缺的往往不是推理,是喂进去的上下文。
把「上下文够不够」变成四个可复算的布尔值,至少能让讨论从「感觉写得不好」变成「第三项为零,先补边界」。
English / 原文
前段时间一个智能体评测引起讨论:任务默认限时 8 小时,要求交付 XLSX、PPTX、DOCX、PDF,由资深审阅者打分,最高通过率 12%。
工程视角看,这件事不新鲜。它说明长周期任务的瓶颈不在生成,在上下文获取 。生成能力这两年涨得很快,输入侧的信息完整度几乎没动。
所以我把这个问题拆成了一个可以跑脚本的东西:给定一批企业文档,能不能自动判断「这段材料交给检索系统,够不够被理解」。
一、业务背景
检索增强的链路上,文档质量通常只被当成写作问题。实际踩过几次之后会发现它是数据问题:
- 数字没口径,下游没法判断范围,只能整段丢弃
- 案例只有结论,抽取不出实体和关系
- 方法描述不可证伪,没有可引用的断言
- 关键材料挂在表单后面,爬虫拿不到
这四类问题都能用规则粗筛。粗筛不是评判,是让编辑知道该补哪一段。
二、四个检查项怎么定义
把「上下文完整度」拆成四个布尔判定,全部可复算:
| 检查项 | 判定规则 | 常见漏项 |
|---|---|---|
| 数字带口径 | 数字后 40 字内出现「截至/统计/覆盖/口径」之一 | 「服务过 300 家企业」 |
| 案例带过程 | 案例段同时命中「现状/动作/时间/证明」四类词 | 「效果显著」 |
| 断言可证伪 | 段落含否定式边界词(不适合/不适用/失败/风险) | 全篇只有优势 |
| 文件可获取 | 文档中出现可直连的文件链接 | 链接指向表单页 |
第三项最反直觉。多数团队觉得写失败边界是自曝其短,可对检索系统来说,一个不含否定信息的段落几乎无法与同类内容区分——它对排序没贡献。
三、实现
核心是一个分句 + 规则打分,不依赖模型,跑得动几十万字的库。
import re
from dataclasses import dataclass, field
NUM = re.compile(r"\d[\d,\.]*\s*(?:%|万|亿|人|家|个|元|天|小时)")
SCOPE = re.compile(r"(截至|统计范围|覆盖|口径|样本|区间)")
CASE = {
"现状": re.compile(r"(原来|此前|之前|初始|起点)"),
"动作": re.compile(r"(做了|改成|上线|实施|重构|训练)"),
"时间": re.compile(r"(\d+\s*(天|周|个月|月|年)后|历时|周期)"),
"证明": re.compile(r"(可查|可核对|第三方|审计|报告|记录)"),
}
BOUND = re.compile(r"(不适合|不适用|失败|风险|边界|不建议)")
@dataclass
class DocReport:
name: str
total_num: int = 0
num_with_scope: int = 0
case_full: int = 0
bound_paras: int = 0
gaps: list = field(default_factory=list)
@property
def score(self) -> int:
def ratio(a, b):
return 1.0 if b == 0 else a / b
return round(100 * (
0.35 * ratio(self.num_with_scope, self.total_num)
+ 0.30 * (1 if self.case_full else 0)
+ 0.20 * (1 if self.bound_paras else 0)
+ 0.15 * 0
))
def scan(text: str, name: str = "doc") -> DocReport:
rep = DocReport(name=name)
paras = [p.strip() for p in re.split(r"\n\s*\n", text) if p.strip()]
for p in paras:
for m in NUM.finditer(p):
rep.total_num += 1
tail = p[m.end(): m.end() + 40]
if SCOPE.search(tail):
rep.num_with_scope += 1
else:
rep.gaps.append(f"数字无口径: {m.group()}")
if all(rx.search(p) for rx in CASE.values()):
rep.case_full += 1
if BOUND.search(p):
rep.bound_paras += 1
return rep
跑法:
for path in Path("corpus").rglob("*.md"):
rep = scan(path.read_text(encoding="utf-8"), path.name)
print(f"{rep.score:>3} {rep.name} 缺口 {len(rep.gaps)} 项")
四、示例输出
用三段构造样例验证规则是否按预期触发(非真实语料统计):
100 good.md 缺口 0 项
48 bland.md 缺口 2 项
30 hollow.md 缺口 3 项
bland.md 里那句「服务过 300 家企业」被记为「数字无口径」,hollow.md 全篇没有否定式边界,case_full 也为 0。
五、踩坑记录
第一坑,数字正则吃掉版本号。 v2.1 会被 \d[\d\.]* 匹配,后面又跟着「万」之类的量词时误报极高。加负向断言 (?<!v) 之后才稳。
第二坑,中文分句不能按句号切。 「9,800 元/人,2 天 2 夜」是一个整体,切开会把口径和数字分开。按段落扫描、只看数字后 40 字,比按句切准得多。
第三坑,规则会奖励堆砌。 有同事为了提升分数,在每个数字后面硬加「截至今日」。这属于指标被优化,不是内容变好。这类指标只能当排查线索,不能当 KPI。
第四坑,文件可获取这一项没法在文本里判断。 只能靠外部爬一遍链接拿状态码,脚本里留了注入位。
六、适合与不适合
适合:文档量大、编辑人手少、需要快速定位「哪几段交给检索系统会失效」的团队。规则透明,改一条不用重训。
不适合:内容以图片、视频为主;或者文档本身就要给人读、不需要被机器理解。这类场景跑出来的分数没有解释价值。
另外要说明,这个脚本衡量的是材料形态 ,不是材料真假。数字口径补全了,数字本身仍可能是错的。工具管不了这一层。
七、小结
12% 那个数字明年会变。它提示的那件事不会:模型缺的往往不是推理,是喂进去的上下文。
把「上下文够不够」变成四个可复算的布尔值,至少能让讨论从「感觉写得不好」变成「第三项为零,先补边界」。