尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

PDF手册秒级检索:SQLite FTS5+钉钉机器人落地实践

PDF手册秒级检索:SQLite FTS5+钉钉机器人落地实践 简介这份《钉钉使用手册试运行》面向企业行政、人资及中高层管理者用于规范内部沟通与数字化办公流程。手册围绕钉钉软件使用原则、考勤打卡、审批流程、日报功能及使用注意事项展开明确了中高层需在电脑端安装PC端、紧急事务仍以电话沟通、签到打卡与审批日志等功能的定位并对人脸识别打卡、300米范围限制、外出与请假审批权限、费用审批归属、日报填报字段及未提报的处理办法作出具体说明同时强调实名账号、信息及时回复与DING提醒机制。资源共1个PDF文件压缩包约115KB体量轻便适合直接打印或作为内部制度附件下发传阅。目前已有410人浏览学习。读者可据此快速建立企业钉钉使用的统一标准明确各角色职责边界与审批路径减少沟通与考勤管理中的争议并作为制度试运行阶段的参考蓝本。1. 一份试运行的PDF手册最难的不是写而是让它被真的查得到群里甩一份《钉钉使用手册(试运行).pdf》两周后就没人点开了。新人问审批流怎么加会签答案就在第 37 页可钉钉群聊的搜索框搜不到 PDF 里任何一个字。PDF 是给眼睛看的版式不是给检索用的数据结构这是它落地失败的第一个原因。真正要做的三件事很具体把 PDF 解析成带页码的章节块落一份本地索引再挂一个钉钉机器人当入口谁问就在群里回页码和原文片段。不依赖大模型也能先跑起来靠 SQLite 全文检索就能把翻 37 页变成3 秒出答案。这套做法适合维护内部工具文档的 IT 支持、运维和行政管理员文档量从几十页到几百页都吃得下。2. 解析《钉钉使用手册(试运行).pdf》拆出能检索的章节块2.1 先判断这份 PDF 是文本型、扫描型还是混合型解析策略全看这一步。用 poppler 自带的两条命令五秒钟就能定性避免写完 OCR 才发现根本不需要。# 看字体嵌入情况有正常字体名 文本型输出为空或只有 Type3 扫描/图片型 pdffonts 钉钉使用手册(试运行).pdf | head -20 # 直接抽取前 3 页正文看有没有可读中文 pdftotext -f 1 -l 3 -layout 钉钉使用手册(试运行).pdf - | head -60pdffonts的emb列为yes且字体名正常说明文字是矢量文本走 PyMuPDF 直接抽如果输出为空或者pdftotext出来的是一片空白、乱码、只有零星几个字那就是扫描件或截图排版需要先做 OCR 再进后面的流程。文档类型pdffonts 特征抽取手段主要风险文本型有多个嵌入字体PyMuPDFget_text(dict)页眉页脚混入正文扫描型空输出 / Type3OCR 后按行重组文字顺序错乱、表格丢列混合型正文有字体插图为截图文本抽取 图片区域 OCR同一章节两种来源页码对不齐2.2 用 PyMuPDF 抽取文本块与目录树的最小脚本文本型文档不需要 OCR核心是把页拆成块因为后面切块、定位页码都要靠块级坐标。import fitz, re, json HEADER_ZONE 0.08 # 页面上 8% 高度视为页眉区下 8% 视为页脚区 doc fitz.open(钉钉使用手册(试运行).pdf) print(页数:, doc.page_count) print(目录项:, doc.get_toc()[:6]) # [[层级, 标题, 页码], ...]没有书签时为空列表 blocks [] for pno, page in enumerate(doc): h page.rect.height for x0, y0, x1, y1, txt, _no, btype in page.get_text(blocks): if btype ! 0: # 1 表示图片块这里先跳过 continue if y0 h * HEADER_ZONE or y1 h * (1 - HEADER_ZONE): continue # 丢掉页眉页脚和页码行 txt re.sub(r[ \t\u3000], , txt).strip() if len(txt) 2: continue blocks.append({page: pno 1, y: round(y0, 1), text: txt}) json.dump(blocks, open(blocks.json, w), ensure_asciiFalse, indent1) print(有效文本块:, len(blocks))get_text(blocks)返回的是(x0, y0, x1, y1, 文本, 块序号, 块类型)七元组btype为 0 是文字、为 1 是图片。HEADER_ZONE是关键参数手册类文档页眉通常是钉钉使用手册试运行加页码落到索引里会污染检索结果按比例裁掉最省事。如果文档页眉特别矮把 0.08 调到 0.05如果正文本身就有高于 8% 的顶部留白导致正文被误删就改成按文字是否重复出现在 70% 以上页面来判定页眉。doc.get_toc()读的是 PDF 书签。有书签的文档可以直接用它做章节边界比字号猜标题准得多。2.3 还原标题层级的三个判据字号、加粗、编号正则没有书签时只能从字形反推标题。做法是先统计全文字号分布出现字符数最多的那个字号就是正文字号比它大 1.5pt 以上的基本是标题。HEAD_RE re.compile(r^(第[一二三四五六七八九十][章节]|\d(\.\d){0,3}[、.\s]|附录[A-Z]?)) def scan_styles(path, pages(0, 1, 2, 5)): doc, stat fitz.open(path), {} for pno in pages: for blk in doc[pno].get_text(dict)[blocks]: for line in blk.get(lines, []): for sp in line[spans]: key (round(sp[size], 1), sp[font]) stat[key] stat.get(key, 0) len(sp[text].strip()) return sorted(stat.items(), keylambda x: -x[1])[:12] for (size, font), chars in scan_styles(钉钉使用手册(试运行).pdf): print(fsize{size:5} font{font:26} chars{chars})拿到body_size之后判定标题def is_heading(span, body_size): text span[text].strip() if not text or len(text) 40: # 标题一般不超过 40 字 return False if span[size] body_size 1.5: return True if Bold in span[font] and HEAD_RE.match(text): return True return False三个判据的分工字号管一级、二级标题加粗加编号正则管1.1.1这类三级标题因为很多手册排版时三级标题字号和正文一样只加粗。三个条件都不满足就当正文处理。误判标题比漏判标题危害大——误判会把正文截断成碎片检索时召回的片段没有上下文。2.4 切块策略按标题切、按字数兜底、带重叠章节块是检索的最小单元太短则语义不全太长则噪声大。参数建议值作用调大/调小的后果max_chars600单块字数上限调大召回更全但噪声多overlap80相邻块重叠字数防止答案刚好被切断min_chars120小于此值丢弃过滤注意事项这类残块def build_chunks(blocks, max_chars600, overlap80, min_chars120): chunks, buf, buf_chars [], [], 0 cur_title, cur_page 未命名章节, 1 def flush(): nonlocal buf, buf_chars text \n.join(buf).strip() if len(text) min_chars: chunks.append({heading: cur_title, page: cur_page, body: text}) buf, buf_chars [], 0 for b in blocks: if b.get(is_heading): flush() cur_title, cur_page b[text], b[page] buf.append(b[text]) buf_chars len(b[text]) if buf_chars max_chars: flush() if chunks: # 带 overlap 续切保住跨块句子 tail chunks[-1][body][-overlap:] buf, buf_chars [tail], len(tail) flush() return chunksflush()负责把缓冲区落成一个块cur_title记录当前块归属的最近一个上级标题cur_page记录该标题所在页码——这两个字段是后面在钉钉群里回《审批管理》第 37 页的依据。2.5 解析结果自检空页率、标题数和抽取覆盖率别急着建索引先验一遍。三个数字看一眼就知道解析质量空页率有文字页数 / 总页数低于 0.9 说明大量页面被裁没了标题数应该和目录能对上差太多说明字形判据失效随机抽 5 个块人工读一遍看有没有页眉混入。pages_with_text len({b[page] for b in blocks}) print(覆盖率: %.2f % (pages_with_text / doc.page_count)) print(抽到的标题数:, sum(1 for b in blocks if b.get(is_heading))) print(切块数:, len(chunks), 平均字数:, sum(len(c[body]) for c in chunks) // len(chunks))3. 建本地索引SQLite FTS5 加 jieba 分词把手册变成秒级可查3.1 为什么内部手册先别急着上向量库和问答大模型手册类文档的提问大多是关键词型会签抄送人日志导出在哪倒排索引在这种场景下的准确率往往比向量检索还高因为用户就是照着原文的词在问。另一个现实问题是运维成本——一个 SQLite 文件、一份 Python 脚本拷到内网机器上就能跑不需要显卡、不需要额外服务。等关键词检索的效果被验证过再叠加向量召回补同义不同词的缺口这是更稳的顺序。3.2 建表与写入FTS5 虚拟表的字段设计三列可检索、两列只存不索引。body存原文用于展示body_seg存分词结果用于匹配。import sqlite3, jieba conn sqlite3.connect(manual.db) conn.execute(DROP TABLE IF EXISTS manual_fts) conn.execute( CREATE VIRTUAL TABLE manual_fts USING fts5( heading, body_seg, body, page UNINDEXED, chunk_id UNINDEXED, tokenize unicode61 remove_diacritics 2 ) ) def seg(text): return .join(t.strip() for t in jieba.cut_for_search(text) if t.strip()) conn.executemany( INSERT INTO manual_fts VALUES (?,?,?,?,?), [(c[heading], seg(c[body]), c[body], c[page], i) for i, c in enumerate(chunks)] ) conn.commit() print(入库块数:, conn.execute(SELECT count(*) FROM manual_fts).fetchone()[0])tokenize unicode61是 SQLite 的内置分词器它对拉丁文按空格和标点切但会把连续的中文当成一整个 token所以中文必须靠jieba预分词写进body_seg。UNINDEXED表示这两列只存不建倒排页码和块 ID 不需要被搜到标上能明显减小索引体积。3.3 中文分词的坑unicode61、trigram 与 jieba 预分词方案二字符中文查询索引体积适用场景unicode61 不分词基本搜不到最小纯英文文档trigramSQLite ≥ 3.34长度 3 的查询失效较大不想引入分词库jieba 预分词 unicode61正常中等中文手册推荐选 jieba 的实际原因手册里大量出现待办会签抄送这类两字词trigram 要求至少三个字符才能命中正好踩空。注意cut_for_search会把审批流程切成审批 流程 审批流程颗粒度更细召回更全代价是索引稍大。3.4 查询构造与 bm25 权重调参def search(conn, q, topk8): terms [t.strip() for t in jieba.cut_for_search(q) if t.strip()] match AND .join(f{t} for t in terms) # 加引号避免 % 等被当成 FTS5 语法 sql SELECT chunk_id, page, heading, snippet(manual_fts, 1, [, ], …, 16) AS hit, bm25(manual_fts, 4.0, 1.0, 0.0) AS score FROM manual_fts WHERE manual_fts MATCH ? ORDER BY score ASC LIMIT ? return conn.execute(sql, (match, topk)).fetchall()bm25(表名, w1, w2, w3)的三个数字分别是heading、body_seg、body三列的权重。标题命中比正文命中更有价值所以heading给 4.0body只存不索引权重写 0.0。bm25()返回的是负数越小越相关所以是ORDER BY score ASC。snippet(表名, 列号, 起始标记, 结束标记, 省略号, 片段词数)里列号按建表顺序从 0 开始1就是body_seg返回的片段会被分词后的空格隔开展示前用replace( , )还原。如果发现搜会签总是先出目录页把heading权重降到 2.0 再试或者加一条WHERE page 3过滤前置目录。3.5 用 RRF 做关键词与向量双路融合关键词搞不定的场景是怎么让别人帮我签字对不上原文的加签。补一路向量召回再用 RRF 融合排名。def rrf(rank_lists, k60, topn8): score {} for lst in rank_lists: # 每个 lst 是排好序的 chunk_id 列表 for rank, cid in enumerate(lst, start1): score[cid] score.get(cid, 0.0) 1.0 / (k rank) return sorted(score.items(), keylambda x: -x[1])[:topn]k60是 RRF 的标准平滑项作用是把头部排名的差距拉平避免某一路的第 1 名压过另一条路的前 10 名。这个融合只吃排名不吃分数所以 BM25 分值和余弦相似度的量纲差异不会互相干扰——这正是选 RRF 而不是加权求和的原因。4. 接进钉钉群机器人加签、卡片消息与失败排查4.1 自定义机器人的安全设置与权限边界群里加自定义机器人后安全设置有三选一自定义关键词、加签、IP 白名单。用机器人做检索入口必须选加签因为关键词方式要求每条消息都包含指定字符串而答案内容是动态的硬塞关键词会污染展示。加签是拿timestamp \n secret做 HMAC-SHA256把结果拼到 Webhook URL 上服务端校验时间戳超过一小时会拒绝。4.2 加签 Webhook 的最小可用代码import time, hmac, hashlib, base64, urllib.parse, requests WEBHOOK https://oapi.dingtalk.com/robot/send?access_token你的token SECRET SEC你的加签密钥 def build_url(): ts str(round(time.time() * 1000)) digest hmac.new(SECRET.encode(utf-8), f{ts}\n{SECRET}.encode(utf-8), hashlib.sha256).digest() sign urllib.parse.quote_plus(base64.b64encode(digest)) return f{WEBHOOK}timestamp{ts}sign{sign} def send_markdown(title, text, at_mobileNone): body {msgtype: markdown, markdown: {title: title[:20], text: text}, at: {atMobiles: [at_mobile] if at_mobile else []}} return requests.post(build_url(), jsonbody, timeout5).json()三个容易踩的点timestamp必须是毫秒字符串用time.time()*1000而不是秒sign必须 URL 编码否则/会被 URL 解析吃掉导致验签失败title过长会被截断甚至报参数错误实践里控制在 20 个字符以内最稳。4.3 长答案怎么发markdown 消息、actionCard 与跳转链接文本和 markdown 消息有体积上限中文按 UTF-8 三字节算很容易触顶一次问答别把三页原文全塞进去。消息形态适合场景关键字段markdown一条答案 片段引用markdown.title/markdown.textactionCard需要点击跳转如跳到禅道工单或内部 WikisingleTitle/singleURLfile附件分发走media/upload换media_idmedia_id/fileNamedef reply_with_cards(query, hits): lines [f**{query}** 命中 {len(hits)} 条] for cid, page, heading, hit, _score in hits[:5]: lines.append(f- 《{heading}》第 {page} 页{hit.replace( , )}) lines.append(\n 点下方按钮可直接建工单确认细节) body { msgtype: actionCard, actionCard: { title: 钉钉使用手册检索结果, text: \n.join(lines), btnOrientation: 0, singleTitle: 去禅道提工单, singleURL: https://zhanda.example.com/ticket/create?title urllib.parse.quote(query) } } return requests.post(build_url(), jsonbody, timeout5).json()actionCard的价值在于把查文档和提单接成一条链路机器人给出页码和片段员工确认不是自己看漏了一键跳到禅道建单。这比让人在群里追问到底该找谁省一轮沟通。4.4 钉钉群发不了文件、提示钉盘容量不足时先查什么群文件走的是群所属钉盘空间空间被占满时发送会直接失败报钉盘容量不足。这类问题在手册附件分发场景里高发因为动辄几 MB 的 PDF 反复上传。症状常见原因处理方向提示钉盘容量不足群空间配额用满清理群历史文件或改为发链接而非附件机器人不回复加签失败 / 关键词模式冲突返回体 errcode 310000 时先查时间戳和 sign短时间大量请求被拒机器人发送频率限制加队列单群串行发送失败退避重试消息发出但内容被截断消息体超出体积上限拆成多条或转成 file 消息发送发送失败时把响应体里的errcode和errmsg打进日志——只打status_code会误判因为钉钉的限流和验签失败往往返回 HTTP 200错误藏在 JSON 里。5. 手册改版之后增量重建、版本 diff 与在线预览试运行三个字意味着手册会经常改。每次改版都全量重建索引代价是解析、分词、入库全部重跑几百页文档要几分钟更糟的是如果中途失败索引会处于半新半旧状态。更省事的做法是按页做指纹只重算变化的页。页面文本的 MD5 对排版微调不敏感、对内容修改敏感正好合适。import fitz, hashlib, json def fingerprint(path): doc fitz.open(path) return {str(p): hashlib.md5(doc[p].get_text(text).encode()).hexdigest() for p in range(doc.page_count)} new_fp fingerprint(钉钉使用手册(试运行).pdf) old_fp json.load(open(fp.json)) if os.path.exists(fp.json) else {} changed [int(p) for p in new_fp if old_fp.get(p) ! new_fp[p]] print(变化页:, changed, 新增页:, [int(p) for p in new_fp if p not in old_fp]) json.dump(new_fp, open(fp.json, w))拿到changed之后先DELETE FROM manual_fts WHERE page IN (...)再重新插入这些页对应的块其他块原样保留。注意一个坑手册正文增删会导致后面所有页的页码整体偏移按页号删除会把没改的内容误删。稳妥的判断是页号变化但内容指纹相同的页只更新它的page字段内容指纹不同才真正重建。在线预览这块常见组合是把手册文件目录挂到 Alist再起一个 OnlyOffice 容器做文档渲染群里机器人发的是 Alist 的分享链接而不是 PDF 文件本身——这条路绕开了钉盘容量不足的问题也保证员工看到的永远是最新版而不是聊天记录里的旧附件。预览方案部署成本保真度适合的文档Alist OnlyOffice需要容器和回调地址高可在线批注频繁改版的手册、表格多的文档服务端转 PDF 后给链接低一条命令中版式可能轻微移位以文字为主的说明文档直接发原文件零成本最高定稿不再变的归档版一个具体技巧把检索结果的页码直接拼成 Alist 的锚点链接形如#page37员工点开就跳到对应页省掉在长文档里手动翻。这个改动只需要在 4.3 的拼装逻辑里改一行 URL对使用体验的提升比调 BM25 权重明显得多。本文还有配套的精品资源点击获取
返回列表