简介:这是一份面向2021款广汽埃安AION S Plus车主的官方电子版用户手册,适合刚提车、准备转售或需要系统熟悉车辆功能的新老车主查阅。手册以PDF形式提供,压缩包内共1个文件,约6.53MB,无需专业基础,按目录即可检索。正文覆盖用户须知、行程准备、绑定车辆、广汽埃安APP、车辆控制、智能钥匙与蓝牙钥匙、车外解锁、车门与行李箱开启、车内照明、前排与后排乘坐、安全带、安全气囊、儿童安全、座椅与后视镜调节、空调多媒体、充电接口、驾驶操作、故障排查及维护保养等模块,并配有操作图示和仪表提示说明。内容按使用场景编排,从日常解锁、启动、行驶到应急处理均有对应章节,可当作随车工具书反复查阅。目前已有893人学习下载,适合希望快速掌握AION S Plus操作要点、减少误操作并延长车辆寿命的车主收藏。
1. 一本 2021 款广汽埃安 AION S Plus 说明书 PDF,值得用代码拆一遍
2021 款广汽埃安 AION S Plus 的使用手册电子版,是一本 300 页上下、图文混排的车主说明书:前段讲钥匙、门锁、座椅调节,中段是驾驶指南和用户操作图示,后段是保养周期表、保险丝对照表和故障灯说明。车主翻起来痛苦,做 IT 的看起来却是一份难得的真实语料,版式稳定、图表密集、术语重复率高,练 PDF 解析、结构化抽取和检索问答都合适。
这里要解决的不是怎么开车,而是怎么用几段 Python 把这份 AION S Plus 说明书 PDF 拆成正文、操作图示和参数表三层数据,再接上一个能听懂「充电口怎么开」「胎压打多少」这类口语提问的检索层。适合做过文档解析、RAG 预处理,或者单纯被一本厚手册折磨过的从业者。
2. 拆 AION S Plus 说明书前,先做一次版式与文本层体检
2.1 先判断文本型电子版还是扫描件,决定要不要上 OCR
同一份 AION S Plus 使用手册电子版,在不同渠道拿到手差别很大:官方导出的往往是文本型 PDF,正文可以选中复制;手机翻拍或者复印后扫描的,整页就是一张图,get_text()只能吐出页眉页码甚至一片空白。不先判断就写抽取逻辑,后面全是无用功。
判断依据不看文件大小,看页级字符密度和图片覆盖面积。字符密度是字符数除以页面面积(单位 pt²),文本型说明书正文页通常在 0.001 以上,扫描页往往低于 0.0003。
| 页级指标 | 文本型电子版 | 扫描型电子版 | 处置 |
|---|---|---|---|
| 正文字符数/页 | 400~1200 | 0~30(只有页眉页码) | 低于 50 直接标记为图像页 |
| 单张图片面积占页面比 | 通常 < 35% | 常 > 85% | 超过 85% 走 OCR 分支 |
| 可提取字号种类 | 2~5 种(正文/标题/图注) | 无 | 无字号则层级得靠视觉模型 |
| 复制粘贴结果 | 正常中文 | 乱码或空 | 乱码说明用了非标准编码映射 |
提示:一本手册里常常两种页面混着出现,比如正文是文本层、插入的参数表是贴图。判断要落在页级,而不是整本 PDF 一个结论。
2.2 AION S Plus 手册的三层内容,各自的解析难点在哪
把手册当纯文本抽,会丢掉一半信息。实际要分成三层:正文流、操作图示、参数表。
正文流里最麻烦的是「警告 / 注意 / 提示」三种标记块,它们字号和正文一样,只靠图标区分,抽取时容易和相邻段落粘成一坨;章节编号形如「3-2」「5.1.3」,是后面分块时最好的天然边界。
操作图示是这本手册的核心卖点,用户在意的「怎么开充电口」「怎么折叠后排」基本都在图里。位图本身抽出来是一张 PNG,但图里的编号(①②③)在文本层里是独立的小字块,坐标和图片重叠。按阅读顺序直抽,这些编号会插进正文中间,把段落切碎。
参数表的字段价值最高,轮胎气压、保养周期、保险丝与继电器对照、灯泡规格、油液容量都在这里,也最容易出错:合并单元格、跨页续表、单位写在表头里。
| 内容层 | 典型页 | 解析难点 | 落地目标 |
|---|---|---|---|
| 正文流 | 驾驶指南、安全须知 | 警告块粘连、章节号不连续 | 带层级的分段 JSON |
| 操作图示 | 用户操作图示章节 | 图内编号混入正文、装饰图干扰 | 图片 + 图注 + 所属章节 |
| 参数表 | 保养周期、保险丝表 | 合并单元格、跨页断开 | 可查询的结构化 CSV |
2.3 用 PyMuPDF 跑一遍页级体检,拿到结构画像
别急着抽内容,先出一份 30 行的页级报表:每页有多少正文、多少图、字号分布如何。后面所有阈值都从这份报表里来。
import fitz # PyMuPDF def pdf_health(path, dump_pages=20): doc = fitz.open(path) report = [] for i, page in enumerate(doc, 1): raw = page.get_text("text") text = raw.strip() area = page.rect.width * page.rect.height density = len(text) / area if area else 0 # 统计字符面积占比最大的图片,用来识别"整页扫描" cover = 0.0 for info in page.get_image_info(): x0, y0, x1, y1 = info["bbox"] cover = max(cover, (x1 - x0) * (y1 - y0) / area if area else 0) # 字号分布:文本型 PDF 靠它判断标题层级 sizes = set() for b in page.get_text("dict")["blocks"]: if b["type"] != 0: continue for line in b["lines"]: for span in line["spans"]: if span["text"].strip(): sizes.add(round(span["size"], 1)) report.append({ "page": i, "chars": len(text), "density": round(density, 5), "img_cover": round(cover, 3), "sizes": sorted(sizes), }) doc.close() return report[:dump_pages] for row in pdf_health("AION_S_Plus_2021_owner_manual.pdf"): print(row)逻辑说明:get_text("text")拿纯文本用来算密度,get_text("dict")拿带坐标和字号的块结构用来算字号分布,两条路分开走,避免互相污染。参数上,density是核心判据,实测文本型正文页大多在 0.001~0.003 之间;img_cover超过 0.85 基本可以断定是扫描页或整页大图;sizes如果只有一个值,说明这份 PDF 的字号信息被压平了,标题层级只能靠章节号正则来补。
拿到这份报表后,可以顺手把页段归类:连续多页img_cover高、chars低的是插图区;chars稳定在 600 以上的是正文区;chars忽高忽低、sizes种类突然变多的,大概率是表格页。
3. 抽取 AION S Plus 说明书的正文、操作图示与参数表
3.1 正文抽取:按文本块聚合,用字号众数判层级
逐行读文本会把一个段落拆成十几条记录,正确做法是按 block 聚合,再用块内字号的众数当作层级信号。同时按 bbox 重新排序,保证同一页里先上后下、先左后右,双栏排版的页也不会串行。
import fitz def parse_manual(path): doc = fitz.open(path) pages = [] for pno, page in enumerate(doc, 1): blocks = [] for b in page.get_text("dict")["blocks"]: if b["type"] != 0: # 0=文本块 1=图片块,这里只处理文本 continue spans = [s for line in b["lines"] for s in line["spans"]] text = "".join(s["text"] for s in spans).strip() if not text: continue sizes = [round(s["size"], 1) for s in spans if s["text"].strip()] # 众数字号:段落里偶尔混进的上标/单位不会带偏层级 mode_size = max(set(sizes), key=sizes.count) if sizes else 0 blocks.append({ "page": pno, "text": text, "size": mode_size, "bbox": [round(v, 1) for v in b["bbox"]], }) # 先按 y0 再按 x0 排序,还原人类阅读顺序 blocks.sort(key=lambda x: (round(x["bbox"][1] / 8), x["bbox"][0])) pages.append(blocks) doc.close() return pages参数说明:b["type"]区分文本块和图片块,分开处理是后面图片抽取的前提;mode_size用众数而不是最大值,因为中文段落里常混入略大的标点或单位符号;排序时把y0除以 8 再取整,是在容忍同一行的轻微基线抖动,直接用原始浮点数排序会因为 0.3pt 的差异把行序打乱。
抽完之后要过滤图注噪声:操作图示里的① ② ③ 编号块通常字高小于 7pt、块宽小于 40pt,用这两个条件可以批量剔除,避免它们插进正文。
3.2 操作图示抽取:用面积和长宽比过滤装饰性图片
一页手册里可能有三张图:一张真正的操作示意图、一条页眉装饰色带、一个品牌 logo。全导出来就是垃圾进垃圾出。过滤靠三个条件——绝对面积、占页比、长宽比。
def is_diagram(page, info, min_area=40000, ratio=(0.25, 4.0), max_cover=0.85): x0, y0, x1, y1 = info["bbox"] w, h = x1 - x0, y1 - y0 area = w * h page_area = page.rect.width * page.rect.height r = w / h if h else 0 if area < min_area: return False # 太小:页码装饰、图标、项目符号 if area / page_area > max_cover: return False # 铺满整页:扫描页或封面,不是插图 if not (ratio[0] <= r <= ratio[1]): return False # 极端长宽比:分隔线、页眉色带 return True参数说明:min_area默认 40000 pt²,大约对应 200×200 pt,低于这个尺寸的图在 A4 页面上没有阅读价值;ratio放宽到 0.25~4.0,是因为竖向的仪表盘示意图和横向的中控台布局图都存在;max_cover用 0.85 而不是 0.5,是为了不误杀那些占了大半页但确实是操作步骤的大图。
导出时按page_{页码}_{序号}.png命名,并把同一页过滤出的图注文本一起写进索引,后面检索时图片和图注才关联得上。
3.3 参数表抽取:把胎压、保养周期、保险丝表落成 CSV
表格抽取和正文抽取用不同工具更稳。PyMuPDF 的文本块在表格里会把单元格内容拍平,pdfplumber 的extract_tables()基于线条识别,对说明书里这种带全框线的表格命中率更高。
import pdfplumber, csv def dump_tables(pdf_path, out_csv, pages=None, min_rows=2, min_cols=2): with pdfplumber.open(pdf_path) as pdf, \ open(out_csv, "w", newline="", encoding="utf-8-sig") as f: writer = csv.writer(f) writer.writerow(["page", "table_idx", "row_idx", "cells"]) for pno, page in enumerate(pdf.pages, 1): if pages and pno not in pages: continue for ti, tb in enumerate(page.extract_tables()): rows = [r for r in tb if any((c or "").strip() for c in r)] # 少于两行两列的多半是误判出来的"表格" if len(rows) < min_rows or max(len(r) for r in rows) < min_cols: continue for ri, row in enumerate(rows): cells = [(c or "").replace("\n", " ").strip() for c in row] writer.writerow([pno, ti, ri, " | ".join(cells)])参数说明:encoding="utf-8-sig"是为了让 Excel 直接打开不乱码,中文说明书场景下这个细节省掉很多来回;min_rows和min_cols是防误判的护栏,说明书里大量带框线的示意图标框会被线条识别算法当成表格;pages参数允许只跑已知的表格页段,全量跑 300 页会比较慢。
| 表格类型 | 主键 | 典型字段 | 落地用途 |
|---|---|---|---|
| 轮胎气压表 | 轮胎规格 | 前轮气压、后轮气压、单位 | 回答「胎压打多少」 |
| 保养周期表 | 里程/时间 | 保养项目、间隔、备注 | 回答「多少公里换什么」 |
| 保险丝对照表 | 编号 | 安培数、保护部件 | 排查电路故障 |
| 灯泡规格表 | 位置 | 型号、功率 | 自行更换前核对 |
抽完 CSV 要做一次单位归一:说明书里同一个气压可能写成 kPa 和 bar 两种,检索层如果不统一,用户问「2.5 是多少」就匹配不上。建议在入库时额外存一列归一化值。
4. 把 AION S Plus 手册做成可检索知识库:分块、索引与召回
4.1 按「章节号 + 功能名」分块,别用固定窗口切
固定 512 字滑窗在说明书上表现很差,它会把「轮胎气压」的表格标题和上一节的「座椅加热」正文切进同一个块,检索出来的结果上下文全错。这类型文档有天然的边界,直接用章节号。
import re CHAPTER = re.compile(r"^\s*(\d{1,2}(?:[\.\-]\d{1,2}){0,3})\s+\S") # 3-2 / 5.1.3 / 12 三种都兜住 def chunk_by_section(pages): chunks, cur = [], None for blocks in pages: for b in blocks: m = CHAPTER.match(b["text"]) if m and b["size"] >= 11: # 字号门槛,避免正文里出现"3-2"被误判 if cur: chunks.append(cur) cur = {"sec": m.group(1), "title": b["text"], "page": b["page"], "body": []} elif cur: cur["body"].append(b["text"]) if cur: chunks.append(cur) for c in chunks: c["text"] = c["title"] + "\n" + "\n".join(c["body"]) return chunks参数说明:CHAPTER正则兼容「3-2」「5.1.3」「12」三种编号风格,说明书里这几种混用很常见;字号门槛>= 11是关键,正文里出现「3-2 档」之类表述时字号和正文一致,不加这道过滤会切出一堆假章节。分块粒度落在 300~800 字,正好覆盖一个完整操作步骤。
4.2 关键词与向量混合检索的最小实现
车主的提问和手册用词不一致:「胎压」对「轮胎气压」,「充电口」对「充电口盖」。纯向量检索能缓解,但对数字、型号这类词不敏感;纯关键词又抓不住同义改写。两边都做,加权合并。
import math, re from collections import Counter SYN = { "胎压": ["轮胎气压", "气压", "标准气压"], "充电": ["充电口", "充电口盖", "慢充", "快充", "充电枪"], "钥匙": ["智能钥匙", "遥控钥匙", "门锁", "无钥匙"], "故障灯": ["警告灯", "指示灯", "报警灯"], } def expand(query): terms = set(t for t in re.findall(r"[\u4e00-\u9fa5]{2,}|[A-Za-z0-9]+", query)) for k, vs in SYN.items(): if k in query: terms.add(k) terms.update(vs) return terms def bm25_lite(tokenized_docs, query, k1=1.5, b=0.75): terms = expand(query) N = len(tokenized_docs) if not N: return [] avg = sum(len(d) for d in tokenized_docs) / N df = Counter(t for d in tokenized_docs for t in set(d) if t in terms) out = [] for d in tokenized_docs: tf, score = Counter(d), 0.0 for t in terms: if not tf.get(t): continue idf = math.log(1 + (N - df[t] + 0.5) / (df[t] + 0.5)) score += idf * tf[t] * (k1 + 1) / (tf[t] + k1 * (1 - b + b * len(d) / avg)) out.append(score) return out逻辑说明:expand做的是查询改写,把口语词映射到手册用词,同义词表按车型手册的高频功能点维护,几十条就够用;bm25_lite里k1=1.5控制词频饱和速度,b=0.75控制长度归一化强度,这两个是 BM25 的标准经验值,说明书这种长度较均匀的语料不需要大改。中文分词用 jieba,缺依赖时退化成二字 bigram,召回略降但不会崩。
向量那一路按章节块的text建索引,最终分数按0.6 * 向量分 + 0.4 * 关键词分合并,权重可以在回归集上调。
4.3 用真实车主问题做回归集,量化召回率
不测就等于没做。把手册能回答的问题攒成一个二十条左右的小集合,每次改完分块或权重就跑一遍。
| 查询样例 | 关键词命中 | 期望章节 |
|---|---|---|
| 胎压打多少 | 胎压 → 轮胎气压 | 轮胎气压表所在章节 |
| 充电口怎么打开 | 充电 → 充电口盖 | 充电章节的操作步骤 |
| 钥匙没电了怎么办 | 钥匙 → 智能钥匙 | 钥匙与门锁章节 |
| 红色电池灯亮了 | 故障灯 → 警告灯 | 故障灯说明章节 |
| 后排座椅怎么放倒 | 无同义词,靠向量 | 座椅调节章节 |
指标只盯一个:前 3 条结果里有没有命中期望章节,记为hit@3。低于 0.8 就回去查是哪类问题丢的——数字类问题丢分通常是分块切断了表格,口语类问题丢分通常是同义词表缺项。
5. 进阶:给 AION S Plus 说明书加一层故障灯到处置步骤的索引
5.1 从故障灯图示反查处置步骤
手册里故障灯部分的版式很有规律:一个仪表盘图示,旁边一列灯名,再往后才是每个灯的处置说明。检索层如果只按文本块切,用户拿到的会是灯名列表,而不是处置步骤。
做法是加一条位置关联规则:把故障灯页里通过 3.2 过滤出的图片,与其右侧或下方 150pt 范围内的文本块绑定,生成{灯图, 灯名, 所在页码}三元组;再用灯名去正文里反查同名小节,把处置步骤挂上去。最终每个故障灯得到一条完整记录,用户问「红色电池灯亮了怎么办」,直接返回图示加三个处置动作。
def link_indicator(page, imgs, blocks, dx=150, dy=150): """把故障灯图片和它右侧/下方的说明文字绑定""" pairs = [] for img in imgs: x0, y0, x1, y1 = img["bbox"] near = [b for b in blocks if b["bbox"][0] >= x0 - 20 and b["bbox"][0] <= x1 + dx and b["bbox"][1] >= y0 - 20 and b["bbox"][1] <= y1 + dy] near.sort(key=lambda b: b["bbox"][1]) pairs.append({"img": img["name"], "labels": [b["text"] for b in near[:3]]}) return pairs参数说明:dx和dy是邻域半径,150pt 大约对应一列文字的宽度,调小会漏掉偏远的说明,调大会把相邻灯的说明拉进来;labels[:3]只取最近的三个文本块,多于三个基本是串页内容。这套邻域绑定思路同样适用于其他图文混排的技术手册,不限于车辆说明书。
5.2 手册改版时只重跑变化页
年款更新后,手册通常只有部分章节变动。全量重跑 300 页的解析加索引,成本不低。用页级指纹做增量:把每页归一化后的文本算一个哈希,新版本解析时对比上一版,只对哈希变化的页重跑正文、图片、表格三条抽取链路,再按章节号把新块替换进索引。
校验环节也要跟着改页跑:变化页涉及的检索回归用例单独标记,改完必须重跑这部分,避免更新后旧答案还在库里。落到具体操作上,就是维护一张page_hash表,加上一条「变化页 → 关联回归用例」的映射,改版时先查表再决定跑多少。
本文还有配套的精品资源,点击获取