1. 项目概述与规划篇:为什么第五周必须做RAG全栈
1.1 本次实践的项目背景与核心目标
这一周,我给自己定的任务是做一个RAG知识库问答系统,并且要求完整走完"从零到生产级部署"的全流程。前面四周,我已经把Python基础、FastAPI后端、React前端、Docker部署这些分散的技能点都过了一遍,但始终觉得它们像是一盘散沙——每次做完一个小demo就结束了,没有一个项目能把所有东西串起来。RAG(Retrieval-Augmented Generation,检索增强生成)恰好是这样一个完美的串联点:它既要有后端接口设计,又要有前端交互页面,还要有Embedding模型调用、向量数据库操作、大模型API对接,最后必然涉及容器化部署和性能调优。
我的目标很明确:不做一个玩具Demo,而是做一个能真正投入使用的内部知识库。我手里恰好有一批公司内部的产品文档和FAQ,总计大约200多份文件,涵盖PDF、Word、Markdown三种格式。这些文档分散在多个目录里,搜索起来极其痛苦,同事之间互相问答全靠口口相传。我给自己定的验收标准是:把这些问题文档导入系统后,可以通过自然语言提问,系统能给出带引用来源的准确回答,并且部署到一台云服务器上,让团队内网可访问。
1.2 为什么选择RAG而不是微调
在动手之前,必须先回答一个最关键的问题:知识库问答方案那么多,为什么我坚定地选择RAG,而不是微调一个开源大模型?
最直接的原因有三个。第一,数据更新速度。我们产品文档几乎每周都有更新,微调模型意味着每次文档变更都要重新训练,成本太高、周期太长。而RAG的方案里,只需要增量更新向量数据库,几分钟就能让新文档生效。第二,回答可解释性。RAG的回答可以附带引用来源,同事看到答案后能点进去核对原始文档,这在企业内部非常重要——大家不会盲目信任一个AI的回答,但会信任有出处的内容。第三,硬件门槛。微调一个7B甚至13B参数的大模型,至少需要一块24GB显存的显卡,成本不低;而RAG方案中,Embedding模型可以用很小的模型,LLM部分直接调用API,开发阶段的硬件投入几乎为零。
当然RAG也有它的问题,最典型的是"检索不准,回答就偏"。这也是我这周花最多时间调优的地方,后面会详细讲。但总体而言,对于企业知识库问答这个场景,RAG是在成本、效果、可维护性三个维度上最均衡的方案。
1.3 技术选型背后的思考
技术选型上,我花了不少时间做对比验证,这里直接给出最终选型结论和理由。
后端框架,我用了FastAPI而不是 Flask 或 Django。理由是:FastAPI原生支持异步接口,而大模型调用和向量检索都是天然的I/O密集型操作,异步能大幅度提升并发能力;加上Pydantic自动做参数校验,开发效率很高。
向量数据库,我选了Qdrant而不是 Milvus 或 Chroma。Chroma 适合单机原型开发,但并发性能和数据持久化做得一般;Milvus 功能强但运维复杂度偏高,对一台2核4G的小服务器来说有点重。Qdrant 是Rust写的,单机性能优秀,Docker部署一条命令就能跑起来,还自带Web UI方便调试,是我这种全栈单兵作战的最佳平衡点。
Embedding模型,我用了开源免费的BAAI/bge-large-zh-v1.5。选择它的原因是中文语义理解效果好,而且在国产GPU和CPU上都能跑。虽然需要本地部署,但换来的是零调用成本和数据私密性,企业内部文档数据不出内网,合规上更稳妥。LLM部分我接了API形式的大模型,因为要保证回答质量,同时不想在开发阶段就被本地小模型的智商限制住。
前端用React + Vite + TypeScript,UI库选了 Ant Design —— 它的组件生态完善,表格、表单、上传、消息提示这些后台系统常用的组件都有现成的,能让我把精力集中在业务逻辑而非样式细节上。
2. RAG核心链路设计:数据预处理与检索召回
2.1 文档解析与切片策略
RAG的流程不复杂:把文档切块、向量化、存入向量库;用户提问时,把问题向量化后去库里检索相似段落,把召回结果拼进Prompt上下文里,最后让大模型基于这些内容来回答。但"不复杂"不等于"容易做好",链路里的每个环节都有可以打磨的细节。
第一步是文档解析。我处理的三类格式中,Markdown最简单,直接用Python读取文本即可。PDF和Word相对麻烦:PDF我用PyMuPDF库来提取文本和图片中的OCR文字;Word文档用python-docx提取正文,遇到表格时会单独抽取并转为Markdown表格结构。这里有个值得注意的细节:PDF的文本提取质量参差不齐,部分扫描版PDF需要先用OCR识别,我用PaddleOCR来处理,准确率不错,但如果文档里有复杂的数学公式或代码块,效果还是会打折扣。
切片策略直接决定检索质量,这是我实验中感受最深的一环。最初我用了最粗暴的固定长度切片——每500个字符切一块,切完直接入库。结果检索出来的内容经常是"断章取义"的:一句话说了一半、表格被拦腰截断、上下文完全丢失。后来我改成了父子切片策略:父块控制在1000个字符左右,子块控制在300个字符左右,向量检索时只对子块做匹配,命中后把子块所属的完整父块作为上下文传给大模型。这样既保证了检索精度,又让大模型得到充足的信息。
2.2 Embedding与向量检索
切片完成之后,就是Embedding环节。bge-large-zh-v1.5模型输出的向量维度是1024维,这个维度在Qdrant里做余弦相似度检索,性能表现很好。有个细节是我实测中发现的:中文文档一定要对切块做分段预处理,不要整篇丢给Embedding模型。bge模型虽然支持最长512个token的输入,但超过一定长度后,语义信息会被稀释,检索效果反而下降。所以我额外做了一个小逻辑:如果切块长度超过300个token,会按句号、感叹号等标点二次拆分,保证实际向量化的文本都在合理长度内。
向量化之后,QA(问答)和普通文档的检索策略其实有些区别。问答类内容我建议用问题直接去匹配;但如果是长文档,仅靠用户输入去检索,往往召回效果不佳。我这里做了查询改写:先用大模型把用户的模糊问题改写成一个更完整、信息量更丰富的检索式问题,再拿改写后的问题去向量库做语义检索。这个"查询改写"步骤看似不起眼,实测下来检索命中率能提升15%到20%。
检索时,我一开始只用向量相似度(dense vector search),但后来发现纯向量检索有个缺陷:它擅长语义匹配,但对于包含精确数字、型号、专有名词的查询,往往不如关键词检索准确。比如用户问"V3.1版本中日志文件的默认路径",如果文档里确实有"/var/log/app_v3.1.log"路径,关键词直接命中远比语义匹配更可靠。所以我把检索升级成了混合检索:向量检索加上基于BM25算法的关键词检索,两者结果做加权融合,再用RRF(Reciprocal Rank Fusion)算法重排。最终效果非常明显——精确查询的准确率从68%提升到了91%。
2.3 Rerank重排与大模型生成
检索召回之后,有一个很多人容易忽略但影响巨大的步骤:重排(Rerank)。向量检索的目的是"快速召回候选",召回量通常会设大一些,比如返回20到30条结果;但真正能放进Prompt上下文的只有3到5条,挑选哪几条就是重排要解决的问题。
我用了一个专门的交叉编码器模型bge-reranker-large来做重排。它和向量的双塔结构不同,是把"问题+候选文档"拼接后一起过模型,直接计算相关性分数,精度明显更高。代价是计算成本高,所以实践中会分两层:第一层用向量/BM25做快速召回,筛选出20条候选;第二层用reranker对20条做精排,选出Top 5作为上下文。这种级联架构兼顾了速度和精度,是生产级RAG系统的常规做法。
重排之后就是Prompt组装了。我用的Prompt模板关键结构是:要求大模型严格基于给定的上下文回答,如果上下文中没有相关信息,必须明确说"未在知识库中找到相关内容",并给出可能的解决方案引导;同时要求回答中标注引用来源编号。这个设计避免了模型一本正经地胡说八道。实测下来,加了这个约束后,幻觉比例从原来的30%多降到了5%以内,效果非常显著。
3. 全栈实现实录:前端交互与后端接口
3.1 后端API设计:不止是提问接口
整个系统后端我拆成了三个核心模块:文档管理模块、检索问答模块、任务调度模块。这里为了让代码结构更清晰,我没有把所有逻辑堆在一个文件里,而是按功能做了分层。
文档管理模块负责文件上传、解析、切片、向量化入库。这个流程是典型的耗时操作,不能同步阻塞请求,所以我用FastAPI的BackgroundTasks提供异步任务处理,上传完成后立刻返回"处理中"状态,前端轮询任务状态获取进展。任务状态存在Qdrant的记录集合里,一方面方便前端展示解析进度,另一方面也记录了每个切片的来源文档,方便溯源。
问答模块的主接口设计得比较讲究,核心是处理流式响应。大模型生成文本是逐字的流式输出,如果等到全部生成完再返回,用户会感觉等了很久;所以我用了Server-Sent Events (SSE)技术,把生成过程实时推送给前端。接口大致结构如下:
from fastapi import FastAPI from fastapi.responses import StreamingResponse from pydantic import BaseModel class QueryRequest(BaseModel): question: str history: list[dict] | None = None app = FastAPI() @app.post("/api/chat/stream") async def stream_answer(request: QueryRequest): async def event_generator(): # 1. 检索重排获取上下文 contexts = await retrieve_contexts(request.question) # 2. 组装prompt并流式调用大模型 async for token in llm_stream_answer(request.question, contexts): yield f"data: {token}\n\n" yield "data: [DONE]\n\n" return StreamingResponse(event_generator(), media_type="text/event-stream")这里有一点要提醒:SSE格式要求数据行以data:开头,以两个换行符结尾,前端EventSource才能正确解析。最后一个[DONE]标记就是流式结束信号,前端的解析器看到它就会停止加载动画。
3.2 前端交互层:从上传到对话的完整体验
前端我做了两个核心页面:知识库管理页和问答对话页。
知识库管理页的核心是文件上传和状态展示。上传组件用Ant Design的Upload.Dragger,支持拖拽和多文件上传;上传后文件列表展示每个文件的处理状态(排队中、解析中、向量化中、已完成、失败)。这个状态列表是前端通过轮询/api/documents/status接口拿到的。一开始我想用WebSocket实现实时推送,后来想想轮询每2秒一次就足够了,实现也简单得多——能用简单方案解决的就别过度设计。
问答对话页采用了类似ChatGPT的流式对话交互。消息气泡组件里,我用了一个自定义的StreamText组件:收到SSE流后,使用requestAnimationFrame逐字渲染文本,模拟打字机效果。这样即便是很长的回答,用户也能在0.5秒内看到第一个字,体感响应速度大幅提升。引用来源部分,我在消息底部渲染了引用卡片列表,点击卡片会打开对应文档切片内容,用户可以直接验证答案的真实性。
3.3 打通前后端:联调中遇到的CORS问题与解决方案
前后端联调阶段我踩了一个非常经典的坑——CORS跨域问题。前端开发服务器跑在localhost:5173,后端API跑在localhost:8000,端口不同就是跨域。浏览器会先发一个OPTIONS预检请求,后端如果不正确响应,前端就收不到真实数据。
解决办法是在FastAPI里配置CORS中间件,允许特定来源访问:
from fastapi.middleware.cors import CORSMiddleware app.add_middleware( CORSMiddleware, allow_origins=["http://localhost:5173"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], )注意生产环境不要写成allow_origins=["*"],如果API涉及带Cookie的请求,通配符加allow_credentials=True的组合会导致预检失败。这里我开发环境和生产环境用了不同的配置,生产环境只允许实际的域名访问。
4. 生产级部署指南:从Docker到云服务器的完整流程
4.1 容器化设计:如何安排各个服务的生命周期
部署阶段是整个项目性价比最高但踩坑最多的一环。最终我采用了Docker Compose编排四个服务:前端Nginx容器、后端FastAPI容器、Qdrant向量数据库容器、Embedding模型服务容器。
这里要特别说明一下Embedding模型服务的部署方式。bge-large-zh-v1.5模型不能直接在FastAPI进程内加载,因为模型启动需要加载约1.3GB的权重文件,如果每次重启后端都要一起加载,整个系统启动时间会超过两分钟。我把模型单独封装成一个小服务,使用sentence-transformers库加载模型,对外提供HTTP接口做文本向量化。这样模型只加载一次,常驻内存,后端和其他服务可以独立重启,互不影响。
Docker Compose的关键配置如下:
version: "3.8" services: qdrant: image: qdrant/qdrant:v1.7.4 container_name: rag-qdrant volumes: - ./data/qdrant_storage:/qdrant/storage restart: unless-stopped networks: - rag-network embedding: build: ./services/embedding container_name: rag-embedding ports: - "8001:8001" restart: unless-stopped networks: - rag-network backend: build: ./services/backend container_name: rag-backend environment: - QDRANT_HOST=qdrant - EMBEDDING_SERVICE_URL=http://embedding:8001 depends_on: - qdrant - embedding restart: unless-stopped networks: - rag-network frontend: build: ./services/frontend container_name: rag-frontend ports: - "80:80" depends_on: - backend restart: unless-stopped networks: - rag-network networks: rag-network: driver: bridge关键点在于容器间通信:Compose会创建一个内部网络,服务之间通过服务名(如qdrant、embedding)互相访问,不需要暴露不必要的端口到宿主机。外部访问只开放80端口(前端),前端再通过Nginx反向代理转发/api请求到后端的8000端口。这样的好处是安全隔离,后端API不会直接暴露在公网。
4.2 性能优化实战:并发、批处理与缓存
部署完成后,第一轮压测就暴露了性能瓶颈。我用locust做了简单的并发测试,结果发现每秒超过5个并发请求时,平均响应时间就飙升到8秒以上。排查后发现三个关键瓶颈,这里逐一说明解决思路。
第一个瓶颈是Embedding服务跟不上。bge-large-zh-v1.5在CPU上处理单个文本要200毫秒左右,高峰时根本来不及。我做了两个优化:一是给Embedding服务加了批量推理能力,后端不再一条一条调用,而是把多个检索请求合并成一个batch发给Embedding服务,模型的吞吐量提升了3倍;二是给Embedding接口加了缓存,对完全相同的文本直接返回缓存向量,避免重复计算。文档管理中的切片向量化也做了缓存处理,重复导入同一份文档时可以直接复用向量。
第二个瓶颈是大模型流式响应的连接管理。后端同时发起多个方向大模型的请求时,需要建立多个连接。我在代码里用了连接池,设置max_connections和超时时间。还有一个细节是很多API有每分钟调用次数限制,请求过多会被限流。我实现了一个信号量机制来控制并发数,实测下来很稳。
第三个瓶颈是前端静态资源体积过大。打包后的JS文件有2.5MB,首屏加载需要3秒以上。我用vite-plugin-compression做了gzip压缩,体积降到800KB;同时把Ant Design的引入方式从全量引入改为按需加载,首屏时间缩短到了1.2秒左右。
4.3 日志、监控与备份:生产系统不能缺的三块拼图
生产环境不能只靠人肉看日志,必须有自动化的监控和告警。我为后端的三个关键接口都加了请求耗时和错误率的监控指标,导出到Prometheus格式,配合Grafana做了一个简单的看板。这个看板能直观展示:每秒请求数、平均响应时间、错误率、向量库查询耗时、大模型调用耗时,一眼看出系统瓶颈在哪里。
日志收集我采用了统一方案:所有服务的日志都输出到Docker的标准输出,然后通过docker logs插件自动收集到本地目录。配合一个cron任务,每天早上自动把前一天的日志打包归档,保留30天。这里有个小经验:一定要在日志里打印请求ID,格式是生成一个UUID放在请求头里,然后在所有日志行里携带。排查问题时,用请求ID就能串联起前端、后端、模型服务的全部日志链路,省太多事了。
数据备份方面,Qdrant的存储目录已经通过volume映射到了宿主机,我写了一个备份脚本,每天凌晨定时把整个data目录压缩后送到另一个磁盘分区。我自己没有做云端异地备份,但如果你的系统更重要,建议至少加OSS或S3异地存储,恢复能力更可靠。
5. 常见问题排查与调优经验分享
5.1 检索效果不理想:从定位到优化的排查路径
这是整个开发过程中最磨人的一个问题,我把它单独拎出来重点说。现象是:用户问了一些业务相关问题,系统返回的答案经常"驴唇不对马嘴"。排查这个问题的思路应该是先定位问题出在哪个环节,而不是盲目调整模型。
第一步,检查检索召回是否准确。我在系统里加了一个调试模式,返回结果中会附带召回内容。如果召回的切块本身就不相关,那就是切块策略或检索策略的问题;如果召回的切块是相关但大模型回答跑偏,那就是Prompt组装或大模型本身的问题。实测下来,80%的情况问题出在切块策略上——切块粒度不合适,导致语义分散或上下文丢失。
第二步,针对切块策略做精细调参。我最终确定的方案是:按文档结构切分,Markdown按标题层级切,Word按段落切,PDF按章节和段落边界切;同时保留父子结构。切片大小取300到500个token。这个调参过程需要反复用一批测试问题做评估。
第三步,用评测集持续跟踪效果。我整理了一份50对"问题-标准答案-来源文档"的评测集,每次调整策略后,都跑一遍评测集,计算检索命中率和回答准确率。不过这项工作其实比较耗时,如果你没有专门的测试集,也可以先从团队实际提问中收集高频问题来搭评测集,这比拍脑袋调参有效得多。
5.2 Embedding模型与部署环境的适配问题
我发现了一个在开发和测试环境完全不同的问题:代码里用的Embedding模型是本地路径,测试机器上跑得好好的,上了云服务器就报ValueError: Could not find model。原因很简单——Docker镜像构建时没有把模型权重打包进去。bge-large-zh-v1.5模型权重1.3GB,不可能每次启动都现场下载,所以我改成了构建镜像时用多阶段构建,把模型文件直接COPY进镜像。
另外一个更隐蔽的坑是模型版本对齐。sentence-transformers库的版本更新很频繁,不同版本的Embedding模型对相同文本产生的向量维度可能不同(更准确地说,是相同模型在不同版本加载后权重微调,向量数值有细微差异)。如果你在开发机用新版本库生成了一批向量,部署机器上用的是旧版本库,新入库的向量和旧向量不在同一个语义空间,检索效果会莫名奇差。解决方案是把sentence-transformers的版本精确锁定在requirements.txt里,开发和部署保持完全一致。
5.3 成本控制经验:一周的API调用账单复盘
最后聊聊成本。我给团队用了一周,每天大约有50人次询问,每个人平均提5个问题。统计下来,大模型API调用消耗的token大约是每天200万左右,按当前的API定价折算,大约每天5到10元人民币;Embedding本地部署零成本,向量库部署在已有服务器上也零成本。这个成本水平完全在可接受范围。
但即使成本不高,我仍然做了几层保护:一是给每个用户设置了每日提问上限;二是对大模型做了缓存——如果同一个问题已经有人问过,并且答案在一周内的文档索引未变,直接返回历史答案,这样热点问题的重复提问不会再产生API调用;三是在Prompt里严格控制输出长度上限。这三板斧下来,实际API消耗比预期少了大约40%。
6. 实战总结与一套可复用的操作清单
6.1 从这一周里提炼出的执行清单
如果让我把这周的实践浓缩成一套可复用的行动指南,我会整理成下面的清单。这套清单现在也是我团队里新人做RAG项目的入门参考,大家反馈照着它可以少踩很多坑。
准备阶段
- 明确RAG不是万能:如果知识库数据量小(少于1000条),直接用大模型的上下文窗口可能更简单;如果要求高精度数学/逻辑推理,RAG也不是最佳选择。
- 梳理文档类型和规模,决定解析方案。OCR需求要提前评估,扫描版PDF的比例决定了解析难度。
开发阶段
- 切块策略是第一优先级,不要用固定长度切块。优先按文档结构切片,配合父子切片策略。
- 检索一定要做混合检索(向量+BM25),然后接Rerank重排。三步缺一不可,每一步都能带来10%以上的效果提升。
- Prompt模板要明确约束"不知道就直说",这是抑制幻觉的有效手段,也是最省成本的优化。
部署阶段
- Docker Compose编排所有服务,模型单独做成服务,持久化数据必须用volume映射出来。
- 日志里统一打请求ID,监控用Prometheus加Grafana,备份每天自动执行。
- 留好调试开关,方便在页面里直接看到召回内容和评分。
上线后
- 建评测集,每次改动前后跑一遍,用数据验证效果而非感觉。
- 有意识地积累用户提问日志,从中挖掘知识库缺失的文档和常见问题,持续优化。
6.2 踩坑较多但值得复盘的五个细节
细节一:Qdrant采集的向量索引参数需要根据数据规模设置。数据量小于几千条时,默认的HNSW参数没问题;但数据量到几十万条后,需要调大m参数和ef_construct,否则召回率和查询速度都会下降。
细节二:大模型的温度参数(temperature)知识库问答场景务必调低。我最终设置在0.1左右,既能保持一定稳定性,又不至于完全机械复述。
细节三:用户上传文档的格式要做好白名单校验和文件大小限制。我在生产环境遇到过有人上传了一个5GB的PDF,直接把解析进程搞挂的场景。现在限制单文件不超过50MB,并且用独立子进程来解析文件,超时自动杀掉,不影响主服务。
细节四:前端流式渲染需要处理异常半程中断的情况。实际网络环境下,SSE连接可能会在中途断开。前端的onerror回调里要确保终止动画并给出提示,否则用户会以为卡死了。
细节五:权限控制不能只做在前端。知识库系统如果涉及敏感内部文档,后端API必须校验用户身份和访问权限;否则别人绕过前端直接调API,就什么都能看了。我这次纯内网环境先做了简单方案,如果你面对的是公网部署,这个环节绝不能省。
6.3 个人实践体会与后续演进方向
五周的AI全栈学习走到这里,我对RAG的理解已经不再是"文档问答"这么简单。它真正考验的是全链路的工程化能力:文档处理的边界情况处理、检索排序的调优能力、前后端交互的流畅度设计、容器化部署的稳定性保障,任何一环短路,整个系统的体验都会打折。这恰恰是全栈工程师的价值所在。
后续我计划在三个方向上继续演进。一是把目前的普通RAG升级为Agentic RAG:让系统不只是"检索-回答"的被动问答,而是能自主规划多步检索流程,在一个问题需要查阅多个文档时自动拆解任务、分步检索、最终整合答案,处理复杂问题的能力会强很多。二是引入Graph RAG:除了向量相似度,再利用知识图谱的实体关系来做推理和检索,特别适合"多个概念之间关联关系"这类问题。三是把文档解析和切片的策略做成可视化配置界面,让非技术同事也能自助调整知识库的预处理逻辑。
这周的项目做完之后,我最深的感受是:技术上没有银弹,好效果都是在一个个细节里抠出来的。切块的粒度、重排的阈值、Prompt的措辞、连接池的大小……每一个变量单独拿出来都不起眼,但它们叠加起来,就是生产级系统和玩具Demo之间的差距。如果你也在做RAG项目,希望这篇文章能帮你少走一些弯路。有任何细节想问的,欢迎在评论区一起讨论。