1. 项目概述当SQL遇上海量文档LangChain Agent如何成为你的“自然语言数据库管理员”你有没有遇到过这样的场景手头堆着几百个PDF报告、上千页的Word会议纪要、几十个Excel数据表还有散落在各处的Markdown技术文档——它们不是结构化数据库但你偏偏需要像查数据库一样快速提问“上季度华东区销售额超50万的客户有哪些”、“所有提到‘碳中和’的政策文件里哪几份明确了2025年减排目标”、“把张工在2023年提交的所有故障分析报告按严重等级排序”。传统方案要么是人工翻找耗时费力要么是硬塞进向量库再做语义检索结果常是答非所问、信息碎片化、缺乏可验证的原始依据。而这个项目标题里的LangChain SQL Agent for Massive Documents Interaction说的就是一套能真正把“非结构化文档海洋”当作“可查询数据库”来用的技术路径。它不靠模糊匹配不靠黑盒生成而是让大模型学会用标准SQL语法去操作文档内容——把PDF解析成带字段的虚拟表把Word段落转为可JOIN的记录把Excel表格直接映射为真实表结构再通过Agent动态生成、校验、执行SQL最终返回带出处锚点的精准答案。这不是概念演示而是我在给某省级政务知识中心做文档治理时落地的生产级方案处理12.7万份政策/法规/通知类文档平均响应时间2.8秒SQL生成准确率91.3%关键字段提取召回率达96.4%。适合正在被文档检索效率拖垮的业务分析师、知识管理工程师、合规审计人员以及任何需要从“文字堆”里挖出结构化事实的从业者。你不需要会写SQL但得理解“字段”“WHERE条件”“JOIN逻辑”这些基本概念你也不必精通LangChain源码但得清楚文档预处理、Schema设计、Agent编排这三道关卡怎么过。2. 整体架构设计与核心思路拆解为什么放弃纯向量检索选择SQL作为交互协议2.1 传统RAG方案的三大硬伤倒逼我们转向SQL范式很多人一提文档问答就默认上RAGRetrieval-Augmented Generation但实际跑通后才发现问题扎堆。我拿自己踩过的坑来说第一次给某银行做信贷合同问答系统用Chroma向量库Llama3-70B用户问“哪些合同约定逾期罚息超过日万分之五”结果返回了17份合同片段但其中3份实际条款是“日万分之四点八”因为向量相似度只认“罚息”“万分之五”这些词频不认数值大小关系第二次做医疗指南问答用户问“糖尿病患者禁用的药物有哪些”模型把“慎用”“忌用”“禁用”全混在一起返回因为向量空间里这三个词距离太近第三次更绝用户问“2022年Q3营收同比增幅”系统从年报PDF里抽出了“同比增长12.3%”但没说明这是“合并报表口径”还是“母公司口径”审计时直接被推翻。这些问题根源在于向量检索本质是语义近似而业务决策需要的是精确逻辑判断。SQL天然具备这种能力——WHERE子句强制数值比较、GROUP BY保证聚合无歧义、JOIN明确关联关系。所以这个项目的核心思路很朴素不把文档当“文本块”喂给模型而是当“数据源”建模。我们不是让模型“猜”答案而是让它“算”答案。2.2 架构分层从原始文档到可执行SQL的四步转化链整个系统不是LangChain开箱即用的SQLDatabaseChain而是基于其Agent机制深度定制的四层流水线文档解析与结构化层不用通用PDF解析器如PyPDF2对扫描件失效而是组合使用pdfplumber保留表格线框、unstructured处理多格式混合文档、tabula-py专攻PDF表格抽取。关键创新点在于对每份文档生成双轨元数据——基础元数据文件名、创建时间、页码 语义元数据用轻量级分类模型标注“合同/报告/政策”类型用NER模型提取“甲方/乙方/金额/日期”等实体。这部分耗时占全流程65%但决定了后续SQL的可靠性。虚拟Schema构建层这才是SQL Agent的灵魂。我们不强行把所有文档塞进一张大表而是按业务域动态建模。比如政策文件域Schema定义为policies(id, title, issuing_authority, issue_date, validity_status, content_text)合同域则为contracts(id, party_a, party_b, amount, sign_date, clause_text)。重点在于content_text字段——它不是整篇文档扔进去而是按段落切分后存为多行记录每行带paragraph_id和page_number这样WHERE条件才能精准定位到具体段落。实测发现段落粒度控制在200-400字符最平衡太短导致上下文断裂如“本合同自双方签字盖章之日起生效”被切成两半太长则降低检索精度。Agent决策层用LangChain的OpenAIAgent或本地部署的Qwen-Agent替代默认SQLAgent。关键改造有三处第一Prompt中嵌入Schema DDL语句并强调“必须严格遵循字段名禁止臆造新字段”第二增加SQL校验步骤——调用sqlglot解析生成的SQL检查语法、表名、字段名是否存在WHERE条件是否含非法函数如NOW()第三执行前强制添加LIMIT 50防止单次查询拖垮数据库。这里有个血泪教训某次上线没加LIMIT用户问“所有合同”Agent生成SELECT * FROM contracts瞬间拉取23万条记录内存溢出。结果增强层SQL执行返回原始数据后不直接丢给LLM总结。而是先做三件事① 对每条记录反向注入来源信息如来源《XX采购合同》第3.2条第12页② 对数值型字段做单位标准化把“50万元”“500,000元”“伍拾万元整”统一转为数字500000③ 对文本字段做关键句高亮用TF-IDF提取与问题最相关的2句。最后才把增强后的数据喂给LLM生成自然语言回答。这步让答案可信度提升40%审计人员一眼就能核对原始依据。2.3 为什么选SQL而非GraphQL或自定义DSL有人会问既然要抽象数据接口为啥不用更现代的GraphQL或者干脆设计个“文档查询语言”DQL我们做过AB测试用GraphQL实现同样功能前端开发时间省30%但后端维护成本翻倍——因为每个文档类型都要写Resolver而政策/合同/报告的字段差异极大Resolver代码重复率高达70%。至于自定义DQL看似灵活实则掉进语法设计陷阱用户要学新语法开发要写新解析器连个基础的BETWEEN操作符都得从零实现。SQL的优势在于它是人类与数据交互的通用母语。业务人员可能不懂Python但Excel里用过SUMIFS法务同事不会写代码但能看懂WHERE clause_text LIKE %违约金% AND amount 100000。我们内部培训时发现教业务方写基础SQL比教他们用RAG界面快5倍——前者2小时能上手查合同后者要反复调试提示词。更重要的是SQL生态成熟PostgreSQL支持JSONB字段存原文SQLite可嵌入客户端甚至Excel都能直连ODBC。这套方案能无缝迁移到企业现有BI工具这才是落地的关键。3. 核心细节解析与实操要点文档解析、Schema设计、Agent调优的硬核细节3.1 文档解析别迷信“一键解析”预处理才是准确率的命门市面上很多方案把解析当黑盒结果90%的错误都出在这里。以PDF为例常见三类陷阱必须手动破扫描件PDF的OCR陷阱pytesseract默认用--oem 3默认OCR引擎对中文表格识别率仅62%。我们改用--oem 1LSTM神经网络引擎--psm 6假设为单块文本并预处理图像先用opencv做二值化cv2.THRESH_BINARY_INV再用cv2.morphologyEx做闭运算连接断裂笔画最后调用tesseract。实测将表格内文字识别准确率从62%提到89%。关键是不要对整页OCR而要先用pdfplumber检测表格区域坐标再截取该区域图像单独OCR——避免页眉页脚干扰。Word文档的样式陷阱python-docx读取时会丢失“标题1”“正文”等样式标记导致无法区分章节标题和普通段落。解决方案是改用docx2python库它能保留style属性。我们约定所有政策文件必须用“标题1”标发文机关“标题2”标文号“标题3”标条款序号。解析时提取style Heading 1的文本作为issuing_authoritystyle Heading 2的作为document_number这样字段提取准确率稳定在95%以上。Excel表格的合并单元格陷阱pandas.read_excel默认把合并单元格填充值导致“甲方”列出现大量重复值。正确做法是用openpyxl加载工作簿遍历ws.merged_cells.ranges对每个合并区域用ws.cell(row, col).value取左上角值再手动填充到所有单元格。我们封装了一个smart_excel_loader函数核心逻辑是from openpyxl import load_workbook def smart_excel_loader(file_path): wb load_workbook(file_path, data_onlyTrue) ws wb.active # 获取所有合并区域 merged_ranges list(ws.merged_cells.ranges) # 创建空DataFrame data [] for row in ws.iter_rows(values_onlyTrue): data.append(list(row)) df pd.DataFrame(data[1:], columnsdata[0]) # 第一行作列名 # 修复合并单元格 for merge in merged_ranges: min_col, min_row, max_col, max_row merge.min_col, merge.min_row, merge.max_col, merge.max_row value ws.cell(min_row, min_col).value for r in range(min_row, max_row 1): for c in range(min_col, max_col 1): if r len(data) and c len(data[r]): data[r][c-1] value return pd.DataFrame(data[1:], columnsdata[0])提示解析阶段务必保存原始坐标信息。我们在每条记录里都存source_file文件名、page_number页码、paragraph_index段落序号。某次审计时用户质疑“这份合同第5条是否真写了免责条款”我们直接用SELECT * FROM contracts WHERE source_fileXX合同.docx AND paragraph_index5查出原始文本3秒完成举证。3.2 Schema设计动态建模比静态建表更能应对文档多样性很多人一上来就建张大表documents(id, type, content, metadata)结果很快撞墙。我们的经验是按业务域建模按文档类型分表按字段重要性分级存储。业务域划分原则不是按文件格式PDF/DOCX而是按业务语义。比如“监管合规域”包含银保监文件、央行通知、交易所规则“内部管理域”包含OA流程、HR制度、IT运维手册。每个域对应一个独立的SQLite数据库文件如compliance.db、internal.db避免跨域查询性能瓶颈。字段分级策略一级字段必存索引idUUID、source_file、issue_date转为DATE类型、validity_statusENUM: active,expired,draft。这些字段高频用于WHERE和ORDER BY必须建B-tree索引。二级字段可选全文索引title、clause_text段落文本。用SQLite的FTS5扩展建全文索引支持MATCH 违约金 NEAR/3 10%这类语义搜索。三级字段JSONB延迟加载raw_content原始未清洗文本、ocr_confidenceOCR置信度、entity_listNER识别的实体列表。这些字段不参与查询只在需要溯源时SELECT raw_content FROM policies WHERE id?按需加载减少I/O压力。动态Schema生成脚本我们写了个schema_generator.py输入文档样本集自动输出建表SQL。核心逻辑是扫描100份样本统计各字段出现频率如issuing_authority在92份中存在对高频字段80%设为NOT NULL对数值字段如amount用正则匹配¥\d\.?\d*自动设为REAL类型对日期字段如sign_date用dateutil.parser试解析成功则设为DATE类型。这样新接入一类文档如招标文件2小时内就能生成可用Schema不用人工逐字段定义。3.3 Agent调优让大模型“懂SQL”而不是“猜SQL”LangChain默认的SQLAgent用llm-math链处理数值但对文档场景水土不服。我们重构了Agent的Tool调用逻辑Tool注册策略不注册SQLDatabaseToolkit而是注册三个原子Toolexecute_sql(query: str) - List[Dict]执行SQL返回字典列表get_schema(table_name: str) - str返回指定表的CREATE TABLE语句search_docs(keyword: str, top_k: int5) - List[str]在全文索引中搜索关键词返回段落摘要。Prompt工程关键点开头强制声明“你是一个严谨的SQL工程师不是自由作家。所有回答必须基于SQL执行结果禁止编造、推测、补充。”在Few-shot示例中放一个典型错误案例用户问“列出所有甲方为‘腾讯’的合同”模型生成SELECT * FROM contracts WHERE party_a 腾讯正确但紧接着又加一句“腾讯是中国互联网巨头”这就是违规——Agent只能返回数据不能解释。加入约束“如果SQL执行返回空结果必须原样返回‘未找到匹配记录’禁止说‘可能不存在’或‘建议换关键词’。”执行层熔断机制我们给execute_sqlTool加了三层熔断语法熔断用sqlglot.parse解析捕获ParseError异常安全熔断正则匹配INSERT|UPDATE|DELETE|DROP|;发现即拒绝性能熔断EXPLAIN QUERY PLAN检查是否走索引若出现SCAN TABLE且表行数10000则返回“查询范围过大请添加更多WHERE条件”。注意千万别用sqlite3的executescript它允许分号分隔多条SQL是重大安全隐患。必须用execute单条执行并严格校验。4. 实操过程与核心环节实现从零搭建可运行的SQL Agent系统4.1 环境准备与依赖安装精简到最小必要集合这套方案刻意避开复杂依赖生产环境只用12个PyPI包远少于动辄30的RAG方案pip install langchain-core0.3.10 \ langchain-openai0.2.10 \ langchain-sqlalchemy0.2.4 \ sqlglot24.4.0 \ pdfplumber0.11.4 \ unstructured0.10.30 \ tabula-py2.12.1 \ python-docx1.1.2 \ openpyxl3.1.2 \ pandas2.2.2 \ sqlalchemy2.0.30 \ pysqlite3-binary0.5.3关键版本锁定原因langchain-core0.3.x 是首个支持RunnableWithFallbacks的版本让我们能实现“SQL执行失败→自动降级到全文搜索”的兜底逻辑sqlglot24.4.0 修复了对SQLiteLIKE通配符的解析bug旧版会把%误判为模运算pysqlite3-binary替代系统SQLite确保支持FTS5全文索引CentOS7默认SQLite太老。实操心得在Docker中部署时用alpine:3.19基础镜像比ubuntu:22.04小470MB但要注意pdfplumber依赖poppler-utils需额外安装apk add poppler-utils tesseract-ocr.4.2 文档解析与入库全流程一个真实合同的处理示例以一份《XX软件采购合同》PDF格式23页为例展示端到端处理步骤1解析PDF获取结构化数据import pdfplumber from unstructured.partition.pdf import partition_pdf # 先用pdfplumber检测表格区域 with pdfplumber.open(XX合同.pdf) as pdf: tables [] for page in pdf.pages: # 检测页面中的表格 extracted_tables page.extract_tables() for table in extracted_tables: # 保存表格坐标和内容 tables.append({ page: page.page_number, bbox: page.bbox, data: table }) # 再用unstructured做文本解析保留标题层级 elements partition_pdf( filenameXX合同.pdf, strategyhi_res, # 高精度模式 infer_table_structureTrue, include_page_breaksTrue ) # 提取关键字段 contract_data { id: str(uuid.uuid4()), source_file: XX合同.pdf, party_a: , # 甲方 party_b: , # 乙方 amount: 0.0, # 金额 sign_date: None # 签署日期 } for el in elements: if 甲方 in el.text and not contract_data[party_a]: contract_data[party_a] el.text.split(甲方)[1].strip().split(\n)[0] elif 乙方 in el.text and not contract_data[party_b]: contract_data[party_b] el.text.split(乙方)[1].strip().split(\n)[0] elif 合同金额 in el.text or 总价 in el.text: # 用正则提取金额 amt_match re.search(r¥?(\d{1,3}(?:,\d{3})*(?:\.\d{2})?), el.text) if amt_match: contract_data[amount] float(amt_match.group(1).replace(,, )) elif 签订日期 in el.text or 签署时间 in el.text: date_match re.search(r(\d{4})年(\d{1,2})月(\d{1,2})日, el.text) if date_match: contract_data[sign_date] f{date_match.group(1)}-{date_match.group(2).zfill(2)}-{date_match.group(3).zfill(2)}步骤2按段落切分并存入SQLiteimport sqlite3 from datetime import datetime # 创建contracts表已提前建好 conn sqlite3.connect(contracts.db) cursor conn.cursor() # 将合同文本按段落切分保留页码 paragraphs [] current_page 1 for el in elements: if el.category PageBreak: current_page 1 elif el.category Text: # 按句号、分号、换行切分段落但避免切碎长条款 text el.text.strip() if len(text) 50: # 长文本才切分 sentences re.split(r[。], text) for sent in sentences: if len(sent.strip()) 20: # 只存有效段落 paragraphs.append({ contract_id: contract_data[id], page_number: current_page, paragraph_text: sent.strip(), created_at: datetime.now().isoformat() }) else: paragraphs.append({ contract_id: contract_data[id], page_number: current_page, paragraph_text: text, created_at: datetime.now().isoformat() }) # 批量插入段落 cursor.executemany( INSERT INTO contracts_paragraphs (contract_id, page_number, paragraph_text, created_at) VALUES (:contract_id, :page_number, :paragraph_text, :created_at) , paragraphs) conn.commit()步骤3构建Agent并执行查询from langchain_core.tools import tool from langchain_openai import ChatOpenAI from langchain.agents import AgentExecutor, create_openai_tools_agent from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder # 定义Tool tool def execute_sql(query: str) - list: Execute a SQL query on the contracts database. Only SELECT allowed. if not query.strip().upper().startswith(SELECT): raise ValueError(Only SELECT queries are allowed) try: conn sqlite3.connect(contracts.db) cursor conn.cursor() cursor.execute(query) result cursor.fetchall() # 获取列名 columns [description[0] for description in cursor.description] return [dict(zip(columns, row)) for row in result] except Exception as e: return [{error: str(e)}] # 构建Agent llm ChatOpenAI(modelgpt-4-turbo, temperature0) tools [execute_sql] prompt ChatPromptTemplate.from_messages([ (system, You are a SQL expert for contract documents. Use only the execute_sql tool. Return raw results, no explanation.), (human, {input}), MessagesPlaceholder(agent_scratchpad) ]) agent create_openai_tools_agent(llm, tools, prompt) agent_executor AgentExecutor(agentagent, toolstools, verboseTrue) # 执行查询 result agent_executor.invoke({input: 列出所有甲方为腾讯的合同金额和签署日期}) print(result[output]) # 输出示例[{amount: 250000.0, sign_date: 2023-05-12}, {amount: 180000.0, sign_date: 2023-08-20}]4.3 性能优化与规模化部署支撑10万文档的实战技巧当文档量从1000份涨到10万份单纯堆硬件解决不了问题。我们通过三重优化把P95响应时间压到3秒内索引策略优化对contracts主表除主键外建复合索引CREATE INDEX idx_party_date ON contracts(party_a, sign_date)对contracts_paragraphs表建全文索引CREATE VIRTUAL TABLE contracts_fts USING fts5(paragraph_text, contentcontracts_paragraphs, content_rowidid)关键技巧对party_a字段用COLLATE NOCASE避免大小写敏感问题“腾讯”和“tencent”都能匹配。缓存层设计不用Redis存SQL结果易过期而是用查询哈希缓存对每个SQL生成SHA256哈希用diskcache.Cache存到本地SSD。缓存Key为fsql_{hash(sql)}Value为执行结果。实测命中率68%节省42%计算资源。代码极简import diskcache as dc cache dc.Cache(./sql_cache) tool def execute_sql_cached(query: str) - list: key fsql_{hashlib.sha256(query.encode()).hexdigest()} if key in cache: return cache[key] result _real_execute_sql(query) # 真实执行函数 cache.set(key, result, expire3600) # 缓存1小时 return result分库分表实践当合同量超5万份单SQLite文件达2GB查询变慢。我们按年份分库contracts_2022.db、contracts_2023.db、contracts_2024.db。Agent层加路由逻辑解析用户问题中的年份如“2023年的合同”自动选择对应数据库。没提年份则并行查询三个库用asyncio.gather合并结果。这样单库控制在500MB内IO性能稳定。5. 常见问题与排查技巧实录那些文档问答里最让人抓狂的Bug5.1 典型问题速查表从报错信息直击根因现象报错信息示例根因分析解决方案SQL生成语法错误sqlglot.errors.ParseError: Unexpected AS at line 1, column 25模型生成了SELECT * FROM contracts AS c但SQLite不支持表别名AS在Prompt中强调“SQLite语法禁止使用AS、WITH等高级特性”并在execute_sql中用正则过滤AS\s\w日期查询全为空SELECT * FROM contracts WHERE sign_date 2023-01-01返回空sign_date字段存的是字符串2023-01-01但SQLite的DATE函数要求ISO格式解析阶段强制用datetime.strptime(date_str, %Y-%m-%d).date()转为DATE类型建表时用sign_date DATE定义中文全文搜索失效SELECT * FROM contracts_fts WHERE contracts_fts MATCH 腾讯无结果FTS5默认tokenize为simple只按空格分词中文需unicode61创建FTS表时指定CREATE VIRTUAL TABLE contracts_fts USING fts5(paragraph_text, tokenizeunicode61)Agent死循环Agent反复调用get_schema不执行SQL用户问题模糊如“查合同”模型不敢生成WHERE条件在Prompt中加入示例“用户问‘查合同’你应返回‘请指定查询条件如甲方名称、金额范围等’”OCR识别乱码SELECT * FROM contracts WHERE party_a 腾讯PDF中中文字体未嵌入OCR输出UTF-8乱码解析前用pdfplumber检测字体if UniGB-UCS2-H not in page.chars[0][fontname]则强制用--oem 1 --psm 1重OCR5.2 调试黄金三步法快速定位Agent卡在哪一步当用户反馈“问了10分钟没结果”别急着重启服务按顺序查查Agent调用日志LangChain的verboseTrue会输出每步Tool调用。重点看是否卡在get_schema说明模型对Schema不熟需补充Few-shot示例是否反复调用execute_sql说明SQL结果不符合预期检查WHERE条件是否合理是否跳过Tool直接返回说明Prompt约束失效检查系统提示词是否被截断。查SQL执行计划在SQLite中执行EXPLAIN QUERY PLAN 你的SQL看是否走索引EXPLAIN QUERY PLAN SELECT * FROM contracts WHERE party_a 腾讯; -- 正确输出SEARCH TABLE contracts USING INDEX idx_party_date (party_a?) -- 错误输出SCAN TABLE contracts ← 这说明索引没建好查文档解析质量随机抽10份文档人工检查contracts表中party_a、amount字段是否为空。若空值率15%说明解析规则需优化——比如合同模板变更甲方字段从“甲方”变成“采购方”。5.3 经验避坑清单那些只有踩过才懂的细节别信文档页码的绝对性PDF页码和pdfplumber解析的page_number可能差1页封面不计数。我们在入库时统一用page_number page.page_number - 1校准确保“第5页”在数据库里就是5。金额字段必须存为REAL不是TEXT曾有项目把金额存为字符串“¥250,000.00”导致WHERE amount 100000永远为假字符串比较。正确做法是入库前float(re.sub(r[^\d.], , text))。Agent的temperature必须设为0哪怕用GPT-4temperature0也会让SQL生成飘忽不定。我们线上环境全部锁死temperature0宁可牺牲一点创造性也要保证确定性。全文索引重建是定时任务不是实时更新FTS5表不支持单条INSERT触发索引更新必须定期INSERT INTO contracts_fts(content) VALUES(rebuild)。我们设每天凌晨2点执行避免影响白天查询。最致命的坑忘记设置SQLITE_MAX_VARIABLE_NUMBERSQLite默认参数最多999个绑定变量当用户问“列出所有甲方为A/B/C/.../Z的合同”26个生成WHERE party_a IN (?,?,...,?)26个问号没问题但若甲方列表有1000个就会报too many SQL variables。解决方案分批查询每批500个用itertools.batched切分。6. 场景延伸与能力边界什么能做什么坚决不做6.1 已验证的高价值场景从文档里挖出结构化事实这套方案在三个场景已跑通商业闭环金融尽调加速某PE机构审阅200份被投公司合同传统方式需3人×5天现用SQL Agent输入SELECT party_b, amount, sign_date FROM contracts WHERE party_a XX基金 ORDER BY sign_date DESC LIMIT 102分钟输出所有合作方清单及金额准确率99.2%人工复核100条仅1条OCR金额小数点错位。政策合规审计某车企法务部监控全国32省市新能源补贴政策用户问“哪些政策对电池能量密度要求≥160Wh/kg”Agent生成SELECT title, issuing_authority, content_text FROM policies WHERE content_text LIKE %电池能量密度% AND content_text LIKE %160%再用正则从content_text中提取数值3秒返回7份政策及具体条款。技术文档知识图谱构建某芯片公司用此方案解析1.2万份Datasheet自动提取chip_model、operating_voltage、max_frequency等字段生成CSV供Power BI分析字段提取F1值达94.7%。6.2 明确的能力边界坦诚告诉你做不到什么我们坚持不承诺做不到的事避免给用户埋雷不做跨文档推理无法回答“对比A合同和B合同的违约责任条款差异”因为SQL是单表操作不支持跨表JOIN除非你手动建视图。这类需求应回归传统RAG。不做主观判断不能回答“这份合同的风险点有哪些”因为风险是法律观点不是文档事实。Agent只返回SELECT clause_text FROM contracts_paragraphs WHERE contract_id xxx AND clause_text LIKE %违约%由律师判断是否构成风险。不做图像内容理解PDF里的流程图、架构图、手写签名OCR无法转为文本Agent自然无法查询。我们会在解析日志中标记has_image: True提醒用户该页需人工核查。不做实时协作编辑系统是只读的不支持用户修改文档内容。所有更新必须走重新解析入库流程确保数据源头一致。我个人在实际交付中最大的体会是文档问答的本质不是技术炫技而是建立人与数据之间的信任契约。当用户问“2023年Q3营收”他要的不是一段LLM生成的文字而是能指向财报第12页第3段的精确答案。SQL Agent的价值正在于它用数据库的确定性锚定了大模型的不确定性。现在回头看当初放弃RAG选择SQL不是技术偏执而是业务倒逼——毕竟在审计现场没人会为一句“根据我的理解…”买单但一行SELECT * FROM financial_reports WHERE period 2023-Q3就是铁证。