← 返回投肯智能知识库首页
首页 / 技术教程 / 企业内部AI知识库从0到1

企业内部AI知识库从0到1:制造业技术文档智能问答系统落地复盘

📖 55分钟更新:2026-05-24

导读

本文复盘一个真实的AI落地项目:某制造业企业(年产值30亿,有3个生产基地)的技术文档智能问答系统。

项目背景: 该企业有20年积累的技术文档(设备手册、维护手册、工艺规范、质检标准等),总计约5万份PDF/Word文档散落在各文件服务器中。工程师日常找资料靠"问老员工"或"翻文件夹",效率极低,且老员工离职后知识流失。

需求: 工程师用自然语言提问,AI从文档库中找到答案,并标注来源。

结果: 上线3个月,日活200+工程师,解决问题准确率从初期72%提升到89%,本文记录完整实施过程、所有踩坑细节、以及可复用的方法论。


1. 项目需求分析

1.1 原始痛点量化

在启动项目前,我们做了2周的现场调研:

| 痛点 | 量化数据 | |------|----------| | 找一份设备文档平均耗时 | 45分钟以上 | | 老工程师带新人时间占比 | 30%用于回答"文档里有"的基础问题 | | 设备故障时,有30%的知识在老员工脑子里,不在文档里 | | 夜班工程师(人员少)遇到问题无法求助 |

1.2 知识库内容盘点

这步很多人忽视,但决定项目成败

`` 文档分布统计(项目实际数据):

格式分布: 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%,需要人工校验或专项处理。


2. 技术方案选型

2.1 为什么选RAG而不是微调

客户最初问:"能不能让AI直接学习这些文档?"

我们选择了RAG(Retrieval-Augmented Generation),理由:

| 对比项 | RAG | 模型微调 | |--------|-----|----------| | 新文档更新 | 实时,自动纳入检索 | 需要重新微调,成本高 | | 事实准确性 | 基于真实文档回答 | 模型可能"记住"错误内容 | | 部署成本 | 中等(向量数据库+LLM API) | 高(需要GPU训练) | | 可解释性 | 可标注来源文档 | 黑盒,不可解释 | | 维护难度 | 低 | 高(微调后模型漂移) |

制造业的技术规范更新频繁,RAG的实时性优势明显。

2.2 技术栈选择

` 整体架构: 文档存储 ──→ 文档解析 ──→ 向量化 ──→ 向量数据库 ↓ 用户提问 ──→ 语义检索 ──→ 重排序 ──→ LLM生成 ──→ 带来源标注的回答 `

| 组件 | 选型 | 选择原因 | |------|------|----------| | 文档解析 | Unstructured-API / PDF.plumber | 支持复杂PDF布局(表格、标题层级) | | OCR | PaddleOCR | 支持中文印刷体,准确率较高,免费 | | Embedding | text-embedding-3-small(OpenAI) | 1536维,中文支持好 | | 向量数据库 | Milvus(开源) | 支持分布式,亿级向量检索 | | LLM | GPT-4o-mini(API) | 成本低,结构化输出稳定 | | 重排序 | BGE-reranker(复旦) | 中文语义重排效果显著 |


3. 实施步骤(完整流程)

3.1 第一步:文档收集与盘点

`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

执行扫描

docs = scan_documents("/mnt/file-server/技术文档/", extensions=['.pdf', '.docx'])

输出统计

print(f"共发现 {len(docs)} 份文档") print(f"总大小: {sum(d['size_mb'] for d in docs):.1f} MB") `

踩坑记录: 扫描时发现有些文件夹有权限问题,用

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")

`

性能数据:

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 ``

坑4:上线后工程师不会提问

问题: 工程师习惯用口语提问,但系统对口语的语义理解有时偏差。

解决: 上线后做了2周"用户引导": 1. 界面提供"示例问题"按钮,点击自动填入问题 2. 做了5分钟的简单使用指引(嵌入产品引导流程) 3. 收集高频低质量问题,优化同义词扩展词典


6. 项目成本拆分

| 阶段 | 成本 | 说明 | |------|------|------| | 文档扫描与解析 | 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研究助手:从需求分析到报告生成的完整流程》