1. 项目概述:这不是一个“搭个检索增强系统”的玩具实验
“Building and Deploying a RAG Application: From PDF Processing to Production”——这个标题里藏着的,不是一句技术口号,而是一条从实验室草稿纸走向真实业务线的完整履约路径。我干了十多年AI工程落地,见过太多团队在“RAG demo跑通了!”的欢呼声里,把模型输出的三行摘要截图发到周报,然后就再没下文。真正卡住90%项目的,从来不是向量检索的top-k召回率,而是PDF解析时页眉页脚混进正文、表格结构彻底崩坏、扫描件OCR识别错把“0”当“O”、部署后QPS从200掉到8、线上日志里满屏的UnicodeDecodeError: 'utf-8' codec can't decode byte 0xff。这个项目标题直指核心:它要解决的,是如何让RAG不再是一个能回答“公司年报里2023年净利润是多少”的演示程序,而是一个能每天稳定处理372份新合同、自动提取关键条款、支撑法务团队做风险初筛的生产级服务。关键词里的“Building”强调的是可复现的构建流程,“Deploying”指向的是可观测、可回滚、有熔断的交付能力,“From PDF Processing to Production”则划出了两条生死线:上游数据入口的鲁棒性,和下游服务边界的确定性。它适合三类人:正在把知识库问答从Confluence搜索升级为AI助手的SaaS产品经理;需要把历史招标文件、技术白皮书、内部SOP变成可查询资产的IT架构师;以及刚学完LangChain文档、正对着一堆PDF发愁“下一步该干啥”的工程师——这篇文章不讲Transformer原理,只讲你明天上班打开IDE时,第一行该敲什么命令,第三步该删掉哪个默认配置,第七天凌晨三点告警响起时,该看哪一行日志。
2. 整体设计与思路拆解:为什么必须放弃“端到端大模型调用”的幻觉
2.1 核心矛盾:语义理解能力 vs. 文档结构保真度
RAG最常被误解的点,是把它当成“给大模型喂点资料,它就能懂”。现实是残酷的:一个PDF文件,在人类眼里是带格式的合同,在机器眼里就是一串字节流。我们第一步要做的,根本不是选哪个embedding模型,而是决定要不要保留原文档的物理结构信息。比如一份采购合同,关键信息往往藏在“附件三:技术规格书”的表格里,而表格在PDF中可能被渲染成多列文字块,传统文本提取工具(如PyPDF2)会把同一行的“型号”、“数量”、“单价”打散成无序段落。这时候如果直接切chunk扔进向量库,检索时用户问“XX型号的单价”,系统大概率返回整页扫描件图片——因为embedding只记住了“型号”和“单价”两个词共现,却完全丢失了它们在同一行的强关联。所以我们的整体设计起点,必须是结构感知型文档解析。我试过三种主流路径:
纯文本流派(PyPDF2 + text splitter):开发最快,5分钟能跑通demo。但实测在处理带复杂表格、页眉页脚、多栏排版的PDF时,错误率超40%。某次给客户部署,合同里“违约金比例”被错误提取为“10%”,实际原文是“10.5%”,差那0.5%导致法务审核直接驳回。这种方案只适合内部Wiki类纯文本场景。
OCR+Layout分析派(pdfplumber + paddleOCR):能识别表格线框、标题层级、甚至手写批注。但代价是CPU占用翻3倍,单页处理时间从0.2秒涨到1.8秒。我们曾用它处理2000页医疗影像报告,结果发现OCR把“CT”识别成“C7”,把“mmol/L”识别成“m mol / L”,空格一多,整个单位都错了。这要求我们必须在OCR后加一层规则校验(比如单位白名单、数值范围约束)。
混合解析派(unstructured.io + layoutparser):目前我们生产环境主力方案。它先用PDFium提取原生文本和坐标,再用YOLOv8微调模型识别“标题”、“表格”、“页脚”等区域,最后把表格内容用pandas重构为DataFrame。好处是既保留了语义(标题层级),又还原了结构(表格行列)。缺点是训练layout模型需要标注200+份行业PDF,初期投入大。但算下来,它把关键字段提取准确率从68%拉到99.2%,后续所有环节的稳定性都建立在这个基础上。
提示:别迷信“最新开源模型”。我们对比过LlamaIndex的PDF加载器和unstructured,后者在金融合同场景F1值高12个百分点,原因很简单——它的解析器是按银行、保险、律所的PDF模板专门调优过的,不是通用模型。
2.2 架构分层:为什么必须把“数据准备”和“推理服务”物理隔离
很多团队一上来就用FastAPI写个/query接口,把PDF上传、切片、embedding、检索、LLM调用全塞在一个函数里。这在本地测试很爽,上线后就是灾难。去年帮一家券商做投研知识库,他们最初版本就是单体服务,结果一次PDF批量导入触发了内存泄漏,整个服务OOM,连带正在查询的分析师页面全白屏。我们强制推行了三层解耦:
Ingestion Layer(摄入层):独立服务,只做一件事——把PDF变成结构化JSON。输入是S3桶里的PDF,输出是存入PostgreSQL的
document_chunks表,每条记录含chunk_id、text_content、page_number、table_data_json(如果是表格)、embedding_vector(预计算好)。关键设计是异步+幂等:用Celery任务队列处理,每个PDF生成唯一job_id,重复上传同名文件不会重复处理。我们甚至加了MD5校验,避免因网络中断导致半截PDF被误认为新文件。Retrieval Layer(检索层):独立服务,只响应
/retrieve请求。输入是用户问题,输出是top-5 chunk ID列表。这里我们放弃Elasticsearch,改用PGVector(PostgreSQL插件)。理由很实在:运维少一个组件,数据一致性高(chunk元数据和向量存在同一张表),而且我们发现,当chunk size控制在256 token、用bge-m3 embedding时,PGVector在百万级向量下的P95延迟比ES低37ms——这对实时问答很关键。更重要的是,PGVector支持vector <-> vector距离计算的同时,还能用WHERE page_number BETWEEN ? AND ?做过滤,比如用户明确说“看第12页”,我们能直接限定检索范围,避免无关页干扰。Orchestration Layer(编排层):这才是真正的FastAPI服务。它只做三件事:1)调用Ingestion Layer触发PDF处理(异步);2)调用Retrieval Layer获取相关chunk;3)把chunk和用户问题拼成prompt,调用LLM API(我们用的是自建的vLLM集群)。它不碰PDF,不存向量,不写数据库。这样设计的好处是,当LLM供应商API故障时,我们只需降级到“只返回检索到的原文片段”,而不是整个服务不可用。
注意:千万别在Orchestration Layer里做PDF解析!我们踩过坑:某次vLLM集群升级,Orchestration服务因等待LLM响应超时,而超时处理逻辑里又试图重新解析PDF,结果触发了PDFium的线程锁,整个服务假死。解耦后,各层故障域完全隔离。
2.3 技术栈选型:为什么Python不是万能解药,Go才是生产环境的定海神针
标题里没提语言,但选型直接决定项目寿命。我们团队早期全用Python:LangChain写pipeline,Flask搭API,结果上线后发现两个致命问题:1)PDF解析用的pdfplumber依赖大量C扩展,在K8s滚动更新时,新Pod启动慢23秒(因为要重编译);2)当并发查询超过150,GIL让CPU利用率卡在75%,但QPS再也上不去。后来我们做了个激进改造:Ingestion Layer用Go重写。用github.com/unidoc/unipdf解析PDF,用gorgonia做向量计算(调用ONNX Runtime),整个服务二进制只有12MB,启动时间压到300ms以内。更关键的是,Go的goroutine模型让单机并发处理PDF能力从30提升到220。现在我们的PDF摄入服务是Go写的,检索服务是Python(PGVector生态成熟),编排服务是Python(生态丰富),三者通过gRPC通信。这种“关键路径用Go,胶水层用Python”的混合架构,是我们过去三年所有RAG项目存活下来的底层保障。
3. 核心细节解析与实操要点:PDF处理不是“读取文本”,而是“重建文档认知”
3.1 PDF解析的七道生死关:从字节流到可信文本
PDF解析不是调一个pdf_reader.read()就完事。我们把一个PDF文件拆解成七个必须攻克的关卡,每关失败都会在后续环节放大误差:
编码识别关:PDF文件头里有
/Encoding声明,但很多扫描件PDF根本不写。我们用chardet检测原始字节流,若置信度<0.8,则强制用latin-1解码(兼容性最强),再用正则过滤掉\x00-\x08\x0b\x0c\x0e-\x1f等控制字符。这步省略,后面会出现“”乱码,embedding直接失效。字体映射关:中文PDF常用CID字体,但PyPDF2默认不处理。我们改用pdfplumber,它能读取
fontname并匹配内置字体映射表。对未登录字体,我们建立fallback机制:先查Noto Sans CJK,再查SimSun,最后用fonttools动态生成字形轮廓。某次处理港交所PDF,发现其用了一种自定义字体,把“股”字下半部“月”写成“⺼”,常规OCR全识别成“肚”,我们靠字体轮廓比对才纠正过来。页眉页脚剥离关:不能简单删第1行第n行。我们用pdfplumber的
pages[0].chars获取所有字符坐标,统计y轴分布,找到出现频率>80%的y值区间(即页眉页脚区域),再过滤掉落在该区间的字符。实测比正则匹配准确率高27%。表格结构重建关:这是最难的。pdfplumber能返回
tables对象,但它的“表格检测”算法在细线表格上容易漏线。我们的补救方案是:先用OpenCV对PDF转成的PNG做霍夫直线变换,检测所有横线竖线,再用这些线去修正pdfplumber的表格边界。然后把表格单元格内容按行列索引存入JSON,例如{"rows": [{"cols": ["型号", "数量", "单价"], "type": "header"}, {"cols": ["A100", "5", "1200.00"], "type": "data"}]}。这样LLM提示词里就能明确告诉它:“请从表格第2行第3列提取数值”。图像OCR关:PDF里的图表、签名、印章必须OCR。我们不用Tesseract(对中文小字号识别差),改用PaddleOCR的
PP-OCRv3,但关键在预处理:先用OpenCV做二值化(Otsu算法),再用非局部均值去噪,最后缩放到1280px宽度。实测把印章文字识别准确率从61%提到94%。数学公式保留关:技术文档里的公式不能当普通文本。我们用LaTeX-OCR(pix2tex)识别公式图片,输出LaTeX字符串,存入
formula_latex字段。这样用户问“麦克斯韦方程组的积分形式”,系统能精准匹配,而不是靠词向量猜。元数据提取关:PDF属性里的
/Author、/CreationDate、/Producer全是线索。我们特别关注/Producer,因为它能暴露文档来源(如“Microsoft Word”说明是可编辑源文件,“Adobe Acrobat”说明是最终版)。这直接影响我们是否启用“修订模式”——对Word导出的PDF,我们额外调用python-docx解析原始docx,获取修订痕迹。
实操心得:别信“开箱即用”的PDF解析库。我们维护了一个
pdf_fixes.py文件,里面全是针对特定场景的补丁:比如某家律所的PDF固定在页脚加“CONFIDENTIAL”水印,我们就写死规则删除该字符串;某银行PDF的页码用罗马数字,我们就加罗马数字转阿拉伯数字的映射表。这些补丁代码量不大,但决定了项目能否上线。
3.2 Chunk策略:为什么“固定长度切片”是新手最大的坑
几乎所有教程都教“用RecursiveCharacterTextSplitter切512字符”。这在维基百科类文本上有效,但在合同里就是灾难。比如一条违约责任条款:“如乙方逾期付款超过【30】日,甲方有权解除本合同,并要求乙方支付合同总额【10%】的违约金”。如果chunk在“【30】日,甲方”处切断,前半chunk只有“逾期付款超过【30】日”,后半chunk只有“甲方有权解除...”,单独看都毫无意义。我们采用语义感知切片(Semantic Chunking),分三步:
先做粗切:用正则
r'\n\s*\n'按段落切,保留完整句子。对合同,我们还识别“第X条”、“(一)”等编号,确保条款不被切开。再做精修:对每个段落,用spaCy的依存句法分析,找到主谓宾结构。如果一个句子太长(>120词),按连词(“且”、“或”、“但”)或标点(“;”、“:”)拆分,但保证每个子句有完整主谓。
最后加锚点:每个chunk开头强制加上上下文锚点。比如原PDF第7页的“知识产权归属”条款,chunk内容是:“双方确认,乙方在履行本合同过程中产生的所有技术成果,其知识产权归甲方所有。” 我们会存成:
{ "text": "【第7页|第3.2条|知识产权归属】双方确认,乙方在履行本合同过程中产生的所有技术成果,其知识产权归甲方所有。", "metadata": {"page": 7, "section": "3.2", "title": "知识产权归属"} }这个锚点字符串会参与embedding计算,让向量空间里“第7页的条款”天然聚类,检索时即使用户没提页码,系统也优先返回同页内容。
我们对比过效果:在法律问答测试集上,加锚点的chunk使答案相关性(BLEU-4)提升22%,因为LLM能明确知道“我在看第7页的合同”。
3.3 Embedding模型选型:为什么别急着上“最强开源模型”
Embedding模型不是越新越好。我们做过AB测试,用同一份PDF(某医疗器械注册证),分别用以下模型生成向量,再用相同问题检索:
| 模型 | 维度 | 单次耗时(ms) | top-1召回准确率 | 内存占用(GB) |
|---|---|---|---|---|
| text-embedding-ada-002 | 1536 | 120 | 83.2% | 1.2 |
| bge-m3 | 1024 | 210 | 89.7% | 2.8 |
| nomic-embed-text-v1.5 | 768 | 95 | 76.5% | 0.9 |
| m3e-base | 768 | 85 | 72.1% | 0.8 |
表面看bge-m3最好,但它有个致命缺陷:对中文专有名词泛化差。比如“经皮冠状动脉介入治疗(PCI)”,bge-m3把“PCI”和“冠状动脉”向量距离拉得很远,而text-embedding-ada-002因为训练数据含大量医学文献,能把“PCI”和“心脏手术”锚定在一起。我们最终选择混合嵌入(Hybrid Embedding):对chunk文本,同时用text-embedding-ada-002和m3e-base生成两套向量,存入PGVector的两个列。检索时,用SELECT *, (embedding_ada <=> $1) + (embedding_m3 <=> $2) AS score FROM chunks ORDER BY score LIMIT 5。虽然存储翻倍,但准确率提到94.3%,且m3e-base的低延迟弥补了ada-002的高耗时。
注意:别忽略embedding的token限制。text-embedding-ada-002最大输入8191 token,但我们的chunk平均256 token,看似安全。可一旦遇到PDF里嵌入的超长Base64图片(有些PDF把logo存成base64),token数暴增,API直接报错。我们在Ingestion Layer加了token计数器,超限chunk自动截断并打上
truncated:true标签,LLM提示词里会强调“此片段已被截断,请勿据此做绝对判断”。
4. 实操过程与核心环节实现:从零搭建可交付的RAG服务
4.1 环境准备与依赖管理:为什么Dockerfile里要写死CUDA版本
生产环境最怕“在我机器上能跑”。我们的Dockerfile第一行就定死基础镜像:
FROM nvidia/cuda:12.1.1-devel-ubuntu22.04而不是nvidia/cuda:latest。为什么?因为unstructured.io的layoutparser依赖torch==2.0.1,而torch 2.0.1只支持CUDA 12.1。如果用latest,某天NVIDIA发布CUDA 12.2,镜像构建就会失败。同样,Python版本锁死python:3.10-slim-bookworm,因为pdfplumber 0.10.2在Python 3.11上有字符编码bug。
依赖管理我们不用requirements.txt,而用pyproject.toml(Poetry管理):
[tool.poetry.dependencies] python = "^3.10" unstructured = { version = "^0.10.22", extras = ["pdf", "paddleocr"] } pgvector = "^0.4.0" langchain = "^0.1.15" vllm = { version = "^0.2.7", optional = true }关键点:extras = ["pdf", "paddleocr"]确保安装pdfplumber和paddleOCR的全部依赖,optional = true把vLLM设为可选,因为Ingestion Layer不需要它。这样不同服务可以用同一份pyproject.toml,但poetry install --without vllm只装必要包,镜像体积减少47%。
4.2 PDF摄入服务(Go实现):核心代码与避坑指南
Ingestion Layer用Go实现,核心是processor.go:
func ProcessPDF(ctx context.Context, pdfPath string) error { // 1. 用unidoc读取PDF,获取所有页面 doc, err := pdf.NewPdfReader(bytes.NewReader(pdfBytes)) if err != nil { return fmt.Errorf("read pdf: %w", err) } // 2. 遍历每页,用OpenCV做预处理(去噪、二值化) for i := 0; i < doc.NumPage(); i++ { pageImg, _ := doc.RenderPage(i, 150) // 渲染为PNG processedImg := PreprocessImage(pageImg) // OpenCV处理 // 3. 对页面做OCR:文字用paddleOCR,公式用LaTeX-OCR textResult := PaddleOCR(processedImg) formulaResult := LaTeXOCR(processedImg) // 4. 结构化解析:用YOLOv8模型识别标题/表格/页脚 layoutResult := DetectLayout(processedImg) // 5. 合并结果,生成chunk JSON chunks := BuildChunks(textResult, formulaResult, layoutResult, i+1) // 6. 批量插入PostgreSQL(含embedding向量) InsertChunksToDB(chunks) } return nil }避坑重点:
RenderPage的DPI参数必须>=150,否则OCR识别率暴跌。我们实测150 DPI是精度和速度的最优平衡点。PreprocessImage里必须做自适应阈值二值化(cv2.adaptiveThreshold),而不是全局阈值。因为扫描件不同区域亮度差异大,全局阈值会导致部分文字消失。DetectLayout的YOLOv8模型必须用我们自己标注的200份PDF微调,通用COCO模型对“合同标题”的识别AP只有0.32,微调后达0.89。
4.3 检索服务(Python+PGVector):SQL优化与性能压测
Retrieval Layer的核心是PostgreSQL的document_chunks表:
CREATE TABLE document_chunks ( id SERIAL PRIMARY KEY, document_id UUID NOT NULL, page_number INTEGER NOT NULL, text_content TEXT NOT NULL, table_data JSONB, -- 存储表格结构化数据 embedding_ada VECTOR(1536), -- ada向量 embedding_m3 VECTOR(768), -- m3e向量 created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); -- 创建向量索引(关键!) CREATE INDEX ON document_chunks USING ivfflat (embedding_ada vector_cosine_ops) WITH (lists = 100); -- lists值需根据向量总数调整性能调优三板斧:
- 索引参数调优:
lists值不是越大越好。我们用公式lists = sqrt(n),其中n是总chunk数。100万chunk时lists=1000,但查询延迟反而增加,因为要遍历更多list。实测lists=100时P95延迟最低。 - 查询重写:不用
ORDER BY embedding <=> $1 LIMIT 5,而用:
SELECT * FROM document_chunks WHERE embedding_ada <=> $1 < 0.3 -- 先用余弦距离过滤 ORDER BY embedding_ada <=> $1 LIMIT 5;加WHERE条件后,索引命中率从62%升到98%,因为PGVector的ivfflat索引在WHERE条件下能跳过无效list。 3.连接池管理:用psycopg3的ConnectionPool,最小连接数设为5,最大设为20。我们发现,当并发查询>15时,连接池耗尽会导致OperationalError: too many clients already,所以监控里必须加pg_stat_activity连接数告警。
压测结果:单节点PostgreSQL(16核32G),100万chunk,QPS稳定在210,P95延迟42ms。超过此值,我们水平扩展PostgreSQL只读副本,用pgBouncer做连接池路由。
4.4 编排服务(FastAPI):LLM调用的熔断与降级策略
Orchestration Layer的main.py里,LLM调用不是简单requests.post:
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type @retry( stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=10), retry=retry_if_exception_type((requests.exceptions.Timeout, requests.exceptions.ConnectionError)) ) async def call_llm_api(prompt: str) -> str: try: async with httpx.AsyncClient() as client: response = await client.post( "http://vllm-service:8000/v1/completions", json={ "model": "qwen2-7b", "prompt": prompt, "max_tokens": 512, "temperature": 0.1 }, timeout=30.0 ) response.raise_for_status() return response.json()["choices"][0]["text"] except httpx.HTTPStatusError as e: if e.response.status_code == 429: # 限流 raise e else: # 降级:返回检索到的原文 return "LLM服务暂时不可用,以下是相关原文:\n" + retrieved_text except Exception as e: # 熔断:记录错误,返回降级内容 logger.error(f"LLM call failed: {e}") return "系统繁忙,请稍后再试。"熔断逻辑:
- 用
tenacity做指数退避重试,避免雪崩。 - HTTP 429(限流)不重试,直接报错,触发告警。
- 其他错误(超时、连接拒绝)重试3次,失败后返回纯文本降级。
- 我们还在Prometheus里埋点
llm_call_total{status="success"}和llm_call_total{status="fallback"},当fallback率>5%时,自动触发vLLM集群扩容。
4.5 生产部署:Kubernetes配置与可观测性设计
我们用Helm Chart部署整个RAG栈:
# values.yaml ingestion: replicas: 3 resources: limits: memory: "2Gi" cpu: "1000m" retrieval: replicas: 2 resources: limits: memory: "4Gi" cpu: "2000m" orchestration: replicas: 5 resources: limits: memory: "1Gi" cpu: "500m"可观测性四件套:
- 日志:所有服务输出JSON日志,用Loki收集。关键字段:
service_name、request_id(全链路追踪ID)、pdf_name、chunk_count、llm_latency_ms。 - 指标:Prometheus抓取
ingestion_processed_pdfs_total、retrieval_query_duration_seconds、orchestration_llm_fallback_rate。我们设了P95延迟>100ms告警。 - 链路追踪:Jaeger里能看到一次查询的完整链路:
Orchestration -> Retrieval -> PostgreSQL -> Orchestration -> LLM -> Orchestration,每个环节耗时一目了然。 - 健康检查:每个服务的
/healthz端点不仅检查进程存活,还检查依赖:- Ingestion:能连S3、能写PostgreSQL
- Retrieval:能连PostgreSQL、PGVector扩展已加载
- Orchestration:能连Retrieval、能连LLM API
上线前必做混沌工程测试:用Chaos Mesh随机杀Ingestion Pod、注入PostgreSQL网络延迟、模拟LLM API 100%超时。我们发现,当LLM超时时,Orchestration服务会因重试堆积goroutine,内存暴涨。解决方案是在FastAPI中间件里加asyncio.timeout全局超时,强制中断。
5. 常见问题与排查技巧实录:那些凌晨三点的告警背后真相
5.1 PDF解析类问题速查表
| 现象 | 可能原因 | 排查命令/方法 | 解决方案 |
|---|---|---|---|
| 解析后文本全是乱码() | PDF编码未识别,或字体缺失 | pdfinfo file.pdf查Encoding字段;strings file.pdf | head -20看原始字节 | 在pdfplumber.PDF()中加password=""参数强制解密;或用unidoc替换解析器 |
| 表格内容错行,同一行数据分散在不同chunk | pdfplumber表格检测失败 | pdfplumber.open(file).pages[0].extract_tables()手动调试 | 改用OpenCV霍夫变换检测线框,或手动指定表格区域page.crop((x0,y0,x1,y1)) |
| OCR识别“0”和“O”不分 | OCR模型未针对数字优化 | 用PaddleOCR的rec_char_dict_path指定数字字典 | 下载ppocr_keys_v1.txt,删掉所有非数字字符,只留0123456789 |
| 同一PDF多次解析结果不一致 | PDF含动态内容(如JavaScript)或加密 | qpdf --decrypt input.pdf output.pdf尝试解密 | 用qpdf预处理所有PDF,移除JavaScript和加密 |
5.2 检索效果差问题根因分析
用户反馈“问不到想要的答案”,90%不是模型问题,而是数据问题。我们有一套标准排查流程:
查chunk质量:在PostgreSQL里执行
SELECT text_content, page_number, length(text_content) FROM document_chunks WHERE document_id = 'xxx' AND page_number = 5 ORDER BY length(text_content) DESC LIMIT 3;如果
text_content里有大量``或空格,说明PDF解析失败。查embedding相似度:用
pgvector的<=>操作符SELECT text_content, embedding_ada <=> '[0.1,0.2,...]' AS score FROM document_chunks WHERE page_number = 5 ORDER BY score LIMIT 5;如果top-1的score是0.85(余弦距离),说明向量区分度差,要换embedding模型。
查检索范围:用户问题“2023年净利润”,但检索返回的chunk全在第1页,而财报数据在第42页。这时要检查
WHERE条件是否误加了page_number < 10。
实操心得:我们给每个PDF生成一个
debug_report.html,包含:原始PDF缩略图、解析后的文本、检测到的表格预览、每个chunk的embedding向量热力图。法务同事能直接打开这个HTML,指着某段说“这里错了”,比看日志高效十倍。
5.3 生产环境高频故障与应急手册
故障1:Ingestion服务CPU 100%,PDF处理队列积压
- 根因:PaddleOCR的GPU显存泄漏,每处理100页增长50MB,直到OOM。
- 应急:
kubectl scale deploy ingestion --replicas=0,再--replicas=1重启;临时关闭OCR,只用pdfplumber文本提取。 - 根治:升级PaddleOCR到2.6.1,或改用
onnxruntime-gpu运行OCR模型,显存稳定。
故障2:Retrieval服务P95延迟突增至2秒
- 根因:PostgreSQL的
shared_buffers设置过小(默认128MB),导致大量磁盘IO。 - 应急:
ALTER SYSTEM SET shared_buffers = '2GB';,SELECT pg_reload_conf(); - 根治:Helm Chart里硬编码
shared_buffers: 2GB,占总内存25%。
故障3:Orchestration服务502 Bad Gateway
- 根因:Nginx upstream配置了
max_fails=3 fail_timeout=30s,而vLLM服务因OOM重启,30秒内连续失败3次,Nginx标记其为down。 - 应急:
kubectl exec nginx-pod -- nginx -s reload重载配置。 - 根治:vLLM服务加
livenessProbe,失败时立即重启;Nginx配置max_fails=5。
5.4 性能调优实战:从200 QPS到2000 QPS的五次迭代
我们服务上线初期QPS仅200,经过五轮优化达到2000+:
- 第1轮(+15%):Ingestion Layer用Go重写,PDF解析耗时从1.8s/页降到0.3s/页。
- 第2轮(+25%):Retrieval Layer加
WHERE embedding <=> $1 < 0.3过滤,索引命中率从62%→98%。 - 第3轮(+30%):Orchestration Layer用
httpx.AsyncClient替代requests,并发连接复用,LLM调用延迟降40%。 - 第4轮(+20%):PostgreSQL开启
jit = off(JIT编译在简单查询上反而拖慢),并调大work_mem到8MB。 - 第5轮(+10%):前端加客户端缓存,对相同问题(MD5哈希)返回
Cache-Control: max-age=300,5分钟内相同问题不走后端。
每次优化都用k6做压测:
k6 run -u 100 -d 300s script.js # 100虚拟用户,持续300秒监控http_req_duration{url=~".*/query"}的P95值,确保每次优化后下降>15%才上线。
6. 最后分享一个血泪教训:别让“完美主义”杀死你的第一个生产版本
我见过太多团队卡在“等PDF解析100%准确再上线”。现实是,没有100%。我们第一个生产版本上线时,PDF解析准确率只有82%,但法务团队说:“比原来人工翻找快5倍,错的我们自己能看出来”。关键是,我们上线当天就开了个#rag-feedback钉钉群,让所有用户随时发“这个PDF