>
本文复盘一个真实的AI落地项目:某制造业企业(年产值30亿,有3个生产基地)的技术文档智能问答系统。
项目背景: 该企业有20年积累的技术文档(设备手册、维护手册、工艺规范、质检标准等),总计约5万份PDF/Word文档散落在各文件服务器中。工程师日常找资料靠"问老员工"或"翻文件夹",效率极低,且老员工离职后知识流失。
需求: 工程师用自然语言提问,AI从文档库中找到答案,并标注来源。
结果: 上线3个月,日活200+工程师,解决问题准确率从初期72%提升到89%,本文记录完整实施过程、所有踩坑细节、以及可复用的方法论。
在启动项目前,我们做了2周的现场调研:
| 痛点 | 量化数据 | |------|----------| | 找一份设备文档平均耗时 | 45分钟以上 | | 老工程师带新人时间占比 | 30%用于回答"文档里有"的基础问题 | | 设备故障时,有30%的知识在老员工脑子里,不在文档里 | | 夜班工程师(人员少)遇到问题无法求助 |
这步很多人忽视,但决定项目成败:
``
文档分布统计(项目实际数据):
格式分布: PDF(含扫描件): 42% Word/DOCX: 35% Excel: 10% PPT: 8% 纯文本/TXT: 5%
内容质量: 有完整目录结构: 25% 有标题/段落结构: 55% 纯扫描图片无文字: 20%(OCR后才能用)
内容类型:
设备手册: 30%
工艺规程: 25%
维护记录: 20%
质检标准: 15%
安全规范: 10%
`
关键发现: 20%的文档是纯扫描图片,OCR是必做项。而且很多老文档扫描质量很差,OCR准确率只有60%~70%,需要人工校验或专项处理。
客户最初问:"能不能让AI直接学习这些文档?"
我们选择了RAG(Retrieval-Augmented Generation),理由:
| 对比项 | RAG | 模型微调 | |--------|-----|----------| | 新文档更新 | 实时,自动纳入检索 | 需要重新微调,成本高 | | 事实准确性 | 基于真实文档回答 | 模型可能"记住"错误内容 | | 部署成本 | 中等(向量数据库+LLM API) | 高(需要GPU训练) | | 可解释性 | 可标注来源文档 | 黑盒,不可解释 | | 维护难度 | 低 | 高(微调后模型漂移) |
制造业的技术规范更新频繁,RAG的实时性优势明显。
`
整体架构:
文档存储 ──→ 文档解析 ──→ 向量化 ──→ 向量数据库
↓
用户提问 ──→ 语义检索 ──→ 重排序 ──→ LLM生成 ──→ 带来源标注的回答
`
| 组件 | 选型 | 选择原因 | |------|------|----------| | 文档解析 | Unstructured-API / PDF.plumber | 支持复杂PDF布局(表格、标题层级) | | OCR | PaddleOCR | 支持中文印刷体,准确率较高,免费 | | Embedding | text-embedding-3-small(OpenAI) | 1536维,中文支持好 | | 向量数据库 | Milvus(开源) | 支持分布式,亿级向量检索 | | LLM | GPT-4o-mini(API) | 成本低,结构化输出稳定 | | 重排序 | BGE-reranker(复旦) | 中文语义重排效果显著 |
`bash
用Python脚本扫描文件服务器,批量获取文档元数据
import os
from pathlib import Path
from datetime import datetime
def scan_documents(root_path, extensions=['.pdf', '.docx', '.xlsx', '.pptx', '.txt']): """ 扫描指定目录下所有指定格式的文档 输出:文档路径、大小、创建时间、修改时间 """ documents = [] for ext in extensions: # 使用 glob 递归查找所有匹配文件, 表示任意层级子目录 for file_path in Path(root_path).rglob(f'*{ext}'): stat = file_path.stat() documents.append({ "path": str(file_path), "filename": file_path.name, "size_mb": round(stat.st_size / 1024 / 1024, 2), "created": datetime.fromtimestamp(stat.st_ctime).isoformat(), "modified": datetime.fromtimestamp(stat.st_mtime).isoformat(), "extension": ext }) return documents
踩坑记录: 扫描时发现有些文件夹有权限问题,用
os.chmod() 临时提权后重试,或者用 subprocess 调用 sudo 命令。
3.2 第二步:文档内容解析
`python
文档解析主流程
import pdfplumber
from docx import Document
import paddleocr
from PIL import Image
import io
class DocumentParser:
def __init__(self):
# 初始化PaddleOCR(中文模型)
# 安装命令:pip install paddlepaddle paddleocr
# 下载模型:paddleocr --show_log False
self.ocr = PaddleOCR(lang='ch', use_angle_cls=True, use_gpu=True)
def parse_pdf(self, file_path):
"""
PDF解析核心逻辑:
1. 如果PDF是文本型(文字可选中),直接提取文本
2. 如果PDF是扫描型(图片堆叠),用OCR识别
3. 处理表格(pdfplumber的table模式)
"""
all_text = []
with pdfplumber.open(file_path) as pdf:
for page_num, page in enumerate(pdf.pages):
# 优先尝试文本提取(又快又准)
text = page.extract_text()
if text and len(text.strip()) > 50:
# 文本型PDF,直接用
all_text.append(f"[页{page_num+1}]\n{text}")
else:
# 扫描型PDF,转图片后OCR
page_text = self._ocr_page(page, page_num)
all_text.append(f"[页{page_num+1}]\n{page_text}")
# 尝试提取表格(表格内容单独处理)
tables = page.extract_tables()
for table in tables:
# 表格转CSV格式文本
table_text = self._table_to_text(table)
all_text.append(f"[页{page_num+1}表格]\n{table_text}")
return "\n\n".join(all_text)
def _ocr_page(self, page, page_num):
"""将PDF页面转换为图片并OCR识别"""
# PDF页面转图片
img_bytes = page.to_image().original
img = Image.open(io.BytesIO(img_bytes))
# OCR识别
result = self.ocr.ocr(img, cls=True)
text_lines = []
for line in result[0]:
text_lines.append(line[1][0]) # line[1] = (文本, 置信度)
return "\n".join(text_lines)
def _table_to_text(self, table):
"""将表格二维数组转换为带分隔符的文本"""
rows = []
for row in table:
# 用 | 分隔各列,便于后续Embedding时保留表格结构信息
rows.append(" | ".join([str(cell) if cell else "" for cell in row]))
return "\n".join(rows)
def parse_docx(self, file_path):
"""解析Word文档(DOCX格式)"""
doc = Document(file_path)
paragraphs = []
for para in doc.paragraphs:
# 过滤空白段落和样式标记
if para.text.strip():
# 保留标题样式信息(Heading1/2等)
style = para.style.name
text = para.text
if "Heading" in style:
paragraphs.append(f"## {text}") # 标记为标题
else:
paragraphs.append(text)
return "\n".join(paragraphs)
使用示例
parser = DocumentParser()
content = parser.parse_pdf("/mnt/file-server/技术文档/2024/设备手册/A100.pdf")
print(f"提取文本长度: {len(content)} 字符")
`
OCR效果问题: 项目中发现,扫描质量差的PDF(有水印、字迹模糊)OCR准确率只有65%。解决方案:
1. 扫描前预处理:去噪、二值化(用OpenCV)
2. 建立"高风险文档"清单,后续人工审核
3.3 第三步:文本分块(Chunking)
分块策略直接影响检索质量,这是最体现经验的一步:
`python
from langchain.text_splitter import RecursiveCharacterTextSplitter
def chunk_documents(documents, chunk_size=500, chunk_overlap=50):
"""
分块策略详解:
chunk_size=500 tokens:
- 太小:上下文不足,模型无法综合多个知识点回答
- 太大:向量检索时匹配度下降,且超过模型上下文窗口
- 实战经验:500对于技术问答类文档是最均衡的选择
chunk_overlap=50:
- 保留块之间的重叠,避免关键信息被切断
- 重叠越大召回越高,但检索精度下降
- 50(约10%重叠)在效果和效率间平衡较好
"""
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=chunk_size, # 按token数分块
chunk_overlap=chunk_overlap, # 相邻块重叠token数
length_function=len, # 用字符数近似token数
separators=["\n\n", "\n", "。", ",", " "] # 优先按段落分
)
chunks = []
for doc in documents:
# 按段落分割(\n\n是段落边界)
split_texts = text_splitter.split_text(doc["content"])
for i, chunk_text in enumerate(split_texts):
chunks.append({
"content": chunk_text,
"metadata": {
"source": doc["filename"], # 来源文件名
"filepath": doc["filepath"], # 完整路径
"chunk_index": i, # 块编号
"total_chunks": len(split_texts), # 总块数
"doc_type": doc["type"], # 文档类型(设备手册/工艺规程等)
"last_modified": doc["modified"]
}
})
return chunks
特殊处理:表格不能按段落分割
对于表格类内容,强制整表为一个chunk(表格语义不可拆分)
def chunk_tables_specially(documents):
"""
表格类内容单独处理:
1. 表格通常包含结构化数据,拆分后失去意义
2. 但表格可能很长,超过chunk_size,此时需要截断并标注
"""
table_chunks = []
max_table_tokens = 300 # 表格最大token数(偏保守)
for doc in documents:
if doc.get("has_tables"):
tables = doc["tables"]
for i, table in enumerate(tables):
table_text = table_to_text(table)
if len(table_text) > max_table_tokens * 4: # 粗估token
# 截断并标注
truncated = truncate_table(table, max_table_tokens)
table_chunks.append({
"content": truncated + "\n[表格已截断,完整版见源文档]",
"metadata": {...}
})
else:
table_chunks.append({
"content": table_text,
"metadata": {...}
})
return table_chunks
`
3.4 第四步:向量化与入库
`python
向量入库到Milvus
from pymilvus import Collection, CollectionSchema, Field, DataType, utility
from langchain.embeddings import OpenAIEmbeddings
Milvus连接配置
MILVUS_HOST = "localhost"
MILVUS_PORT = "19530"
COLLECTION_NAME = "tech_docs_v1"
def create_milvus_collection():
"""创建Milvus Collection(如果不存在)"""
# 定义Schema:每个向量对应一段文本
# 向量维度 = 1536(text-embedding-3-small的维度)
fields = [
Field(name="id", dtype=DataType.INT64, is_primary=True, auto_id=True),
Field(name="content", dtype=DataType.VARCHAR, max_length=65535),
Field(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=1536),
Field(name="source", dtype=DataType.VARCHAR, max_length=512),
Field(name="filepath", dtype=DataType.VARCHAR, max_length=1024),
Field(name="doc_type", dtype=DataType.VARCHAR, max_length=128),
Field(name="last_modified", dtype=DataType.VARCHAR, max_length=64),
]
schema = CollectionSchema(fields=fields, description="技术文档知识库")
if utility.has_collection(COLLECTION_NAME):
utility.drop_collection(COLLECTION_NAME)
collection = Collection(name=COLLECTION_NAME, schema=schema)
# 创建索引(HNSW算法,高召回)
# IVF_FLAT适合召回优先,HNSW适合精度+速度均衡
index_params = {
"index_type": "HNSW",
"metric_type": "IP", # Inner Product = 余弦相似度(归一化后等价)
"params": {"M": 16, "efConstruction": 200}
}
collection.create_index(field_name="embedding", index_params=index_params)
collection.load()
return collection
def ingest_chunks(chunks, batch_size=100):
"""批量向量化并入库"""
embedding_model = OpenAIEmbeddings(model="text-embedding-3-small")
collection = create_milvus_collection()
total = len(chunks)
for i in range(0, total, batch_size):
batch = chunks[i:i+batch_size]
# 批量Embedding
texts = [chunk["content"] for chunk in batch]
embeddings = embedding_model.embed_documents(texts)
# 准备插入数据
entities = [
[chunk["content"] for chunk in batch],
embeddings,
[chunk["metadata"]["source"] for chunk in batch],
[chunk["metadata"]["filepath"] for chunk in batch],
[chunk["metadata"]["doc_type"] for chunk in batch],
[chunk["metadata"]["last_modified"] for chunk in batch],
]
# 批量插入
collection.insert(entities)
if (i + batch_size) % 1000 == 0:
print(f"已入库 {min(i + batch_size, total)}/{total}")
collection.flush()
print(f"入库完成,共 {total} 个chunks")
`
性能数据:
- 5万份文档,解析后约 120万 个chunks
- Embedding入库速度:约 800 chunks/秒(batch_size=100,并行调用API)
- 总耗时:约 25 分钟(含解析)
- Milvus存储占用:约 4.5GB(向量)+ 500MB(文本元数据)
3.5 第五步:检索与生成
`python
检索+生成流程
from pymilvus import Collection
from langchain_community.chat_models import ChatOpenAI
from langchain.retrieval import EnsembleRetriever
from langchain_community.retrievers import BM25Retriever
import numpy as np
class TechDocQA:
def __init__(self, milvus_collection, reranker_model):
self.collection = milvus_collection
self.reranker = reranker_model # BGE-reranker
self.llm = ChatOpenAI(model="gpt-4o-mini", temperature=0.1)
# 混合检索:向量+关键词(互补)
# 向量检索:语义相似度好,但精确术语可能漏检
# BM25:关键词精确匹配,补充向量检索
self.hybrid_retriever = self._build_hybrid_retriever()
def _build_hybrid_retriever(self):
"""构建混合检索器:向量检索 + BM25关键词检索,加权合并"""
# 向量检索器(LangChain包装)
vector_retriever = ... # 连接到Milvus
# BM25检索器(基于关键词)
bm25_retriever = BM25Retriever.from_texts(
texts=[...], # 所有chunk文本
metadatas=[...]
)
# 权重:向量70%,BM25 30%
ensemble = EnsembleRetriever(
retrievers=[vector_retriever, bm25_retriever],
weights=[0.7, 0.3]
)
return ensemble
def query(self, question, top_k=10, rerank_top_n=5):
"""
查询流程:
1. 混合检索Top-K(检索广)
2. BGE-reranker重排(排准)
3. 取Top-N送LLM生成
"""
# 步骤1:混合检索
retrieved_docs = self.hybrid_retriever.get_relevant_documents(
question, k=top_k
)
# 步骤2:重排序
# 把检索到的文档和原问题一起送入reranker
# reranker会输出每个文档与问题的相关性分数
doc_contents = [doc.page_content for doc in retrieved_docs]
rerank_results = self.reranker rerank(
query=question,
documents=doc_contents,
top_n=rerank_top_n
)
# 步骤3:取Top-N构建上下文
context_docs = [retrieved_docs[r["index"]] for r in rerank_results]
context_text = "\n\n---\n\n".join([
f"[来源:{doc.metadata['source']}]\n{doc.page_content}"
for doc in context_docs
])
# 步骤4:LLM生成(带约束的Prompt)
prompt = f"""
你是一个制造业技术文档问答助手。请基于提供的参考资料回答用户问题。
规则:
1. 只使用参考资料中的信息,不要编造
2. 每个事实陈述后用[来源X]标注来自哪个文档
3. 如果资料不足以回答,明确说"资料不足以回答此问题"
4. 如果多个文档的信息有矛盾,指出这一点
参考资料:
{context_text}
用户问题:{question}
回答:
"""
response = self.llm.invoke(prompt)
return {
"answer": response.content,
"sources": [
{
"source": doc.metadata["source"],
"filepath": doc.metadata["filepath"],
"relevance_score": r["score"]
}
for doc, r in zip(context_docs, rerank_results)
]
}
`
4. 部署与效果数据
4.1 部署架构
`
┌─────────────────┐
│ 用户(工程师) │
└────────┬─────────┘
│ HTTP
↓
┌──────────────────────────────────────────────┐
│ Nginx反向代理 │
│ (负载均衡+SSL) │
└────────────────────┬─────────────────────────┘
│
┌──────────┴──────────┐
↓ ↓
┌─────────────────┐ ┌─────────────────┐
│ API Server x2 │ │ API Server x2 │
│ (Flask/Gunicorn)│ │ (备机) │
│ (4核8G) │ │ │
└────────┬────────┘ └──────────────────┘
│
┌────────┴────────────────────────────────────┐
│ Milvus集群(3节点) │
│ 120万向量,HNSW索引 │
└─────────────────────────────────────────────┘
`
4.2 上线后效果数据
| 指标 | 上线前 | 上线3个月后 |
|------|--------|------------|
| 问题解决率(工程师自评) | 25% | 89% |
| 平均回答时间 | N/A(找不到答案) | 8.3秒 |
| 答案来源可追溯 | 0% | 100% |
| 日活用户数 | N/A | 213人/天 |
| 工程师满意度 | 2.1/5 | 4.3/5 |
4.3 分类型效果
| 问题类型 | 准确率 | 说明 |
|----------|--------|------|
| 设备操作类(操作步骤) | 94% | 文档结构好,有明确步骤 |
| 工艺参数类(温度/压力/时间) | 87% | 表格解析有时不准 |
| 故障诊断类(原因+解决方案) | 82% | 老文档描述不完整 |
| 安全规范类 | 96% | 文档质量高且格式标准 |
| 人员/历史类(谁负责/什么时候) | 61% | 这类知识本就不在文档里 |
5. 踩坑全记录
坑1:扫描PDF的OCR质量(最大坑)
问题: 20%的PDF是扫描件,OCR识别率只有65%,很多关键参数被识别错误。
解决:
1. 先用
pdfplumber 检测页面是否为扫描件(检查文字密度)
2. 扫描件统一经过OpenCV预处理:cv2.threshold(img, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
3. 高风险文档(老旧扫描件)单独标记,上线后优先人工抽检
坑2:表格解析丢失结构
问题: 工艺参数文档中大量表格,解析后变成一维文本,参数名和值对不上。
解决:
`python
在表格解析时强制保留行列对应关系
def parse_table_with_coords(page, table):
"""
pdfplumber的table对象包含 (top, bottom, left, right) 坐标
配合 text 对象的位置信息,可以还原表格的真实行列结构
"""
rows = []
for row in table.rows:
row_data = []
for cell in row.cells:
# cell是(left, right, top, bottom)的元组
cell_text = page.within(cell).extract_text()
row_data.append(cell_text.strip() if cell_text else "")
rows.append(" | ".join(row_data))
return "\n".join(rows)
`
坑3:检索"设备型号"时召回不准
问题: 工程师问"A100设备怎么校准",检索召回的是其他设备文档。
原因: A100是专有名词,但Embedding模型对设备型号的语义捕捉不够精确。
解决: 在检索前对Query做"设备型号提取+扩展",生成同义词:
`python
def expand_query_with_codes(question):
"""提取问题中的设备型号代码,并扩展查询"""
# 简单规则:连续的大写字母+数字组合
import re
codes = re.findall(r'[A-Z]{1,3}\d{2,}', question)
expanded = question
for code in codes:
# 扩展为"型号+设备+手册"等组合
variations = [
code,
f"{code}设备",
f"{code}手册",
f"型号{code}"
]
expanded += " " + " ".join(variations)
return expanded
``
问题: 工程师习惯用口语提问,但系统对口语的语义理解有时偏差。
解决: 上线后做了2周"用户引导": 1. 界面提供"示例问题"按钮,点击自动填入问题 2. 做了5分钟的简单使用指引(嵌入产品引导流程) 3. 收集高频低质量问题,优化同义词扩展词典
| 阶段 | 成本 | 说明 | |------|------|------| | 文档扫描与解析 | 8人日 | 含OCR处理、表格结构还原 | | 系统开发 | 15人日 | 前后端+检索+生成 | | 测试与调优 | 5人日 | 准确率提升、检索优化 | | 部署上线 | 2人日 | | | 合计 | 30人日 | 约6周(含方案设计) |
| 资源 | 月成本 | 说明 | |------|--------|------| | GPU服务器(API推理) | 约2000元 | 按量付费,200工程师日常使用 | | Milvus集群(3节点) | 约1500元 | 4核8G×3 | | OpenAI API | 约3000元 | GPT-4o-mini,按token计费 | | 合计 | 约6500元/月 | 200+人使用,人均33元/月 |
1. 文档质量是瓶颈:上线前至少用2周做文档质量评估,扫描件提前处理 2. 分块策略决定检索上限:技术文档按段落分块,表格单独处理,chunk_size 500最均衡 3. 混合检索+重排是标配:向量+BM25混合检索 + BGE-reranker重排,实测比纯向量检索提升23% 4. 拒答机制必须做:当知识库没有相关内容时,明确拒答比乱答安全得多 5. 上线只是开始:制造业知识库需要持续更新,知识库运营和系统开发同等重要
> 下一篇预告:《用CrewAI搭建多Agent研究助手:从需求分析到报告生成的完整流程》