内部知识库、产品手册、合同档案……每个团队硬盘里都躺着成堆的文档,可真正遇到问题的时候,很少有人能说清楚该翻哪份材料。大语言模型的热潮给了这种场景一个很自然的解法:文档问答系统。我这次选的技术组合是 FastAPI + RAG(检索增强生成),目标是搭一个能接收各类文档、回答提问、并给出引用依据的智能问答服务。整套系统不依赖昂贵的训练集群,一台开发机就能跑完闭环,但从接口层面看,它已经是一个实际可用的产品雏形。
这篇记录适合正在评估RAG方案的后端工程师、想把内部文档变成可检索问答系统的技术负责人,以及刚接触大语言模型应用开发、想找一份完整落地参考的开发者。全文按知识库构建、服务层设计、接口实现、质量调优、问题排查和生产化建议的顺序展开,不绕弯子,直接讲我实际怎么做的,以及为什么这么做。
1. 为什么最终没有选“微调”:RAG的取舍逻辑
1.1 大模型的“知识边界”是硬伤
直接让模型答内部问题,最大的问题是它根本不知道你的文档里写了什么。通用大模型的知识来自公开语料,而且有一个训练截止时间;公司内部的项目文档、客服话术、产品参数更新,它一概不了解。要填补这个空隙,总有人第一反应是“把文档全部塞进提示词”,但上下文窗口有限,文档一大就爆;即便塞得下,每次请求传一堆无关内容,成本和延迟也直线上升。
另一条思路是微调,把文档知识写进模型参数。听上去很美,但实际做一次就知道有多重:每次文档更新,你都得重新训练一轮;微调产生的“记忆”无法对应到具体出处,用户问一句“这个结论哪来的”,你只能说“模型培训时学的”。企业内部答疑必须要能对得上原始材料,这是刚需。而且微调需要用高质量的问答对做标注数据,覆盖面稍微差一点,模型表现就越不稳定。它更适合改变模型的输出风格、说话语调、领域术语习惯,而不是用来装新知识。
1.2 RAG的核心是把“记住”和“思考”解耦
RAG的设计方式是开卷考试:不要求模型死记硬背,而是带它逛一趟图书馆。系统先从知识库里通过语义检索召回与问题最相关的一小部分文档片段,再把“问题+片段”拼成提示词交给模型,模型只根据这些可见材料作答。整个过程可以拆成两个阶段:
索引阶段:文档清洗、切片、向量化,写进向量数据库。相当于给每本书做目录、贴标签、上“语义索引”的书架。
查询阶段:把用户问题转成向量,检索最相似的若干片段,把片段和问题一起交给大模型生成回答。
这种拆分的好处很明显:知识更新时,只需要重新导入那几份文档,不用动模型;回答的每个结论都能指着某一段原文说“依据在这里”。责任边界清楚,系统的可维护性高很多。
1.3 不要非此即彼,RAG和微调可以叠着用
我观察到一个误区,很多人把RAG和微调看成“二选一”。实际上生产系统里更常见的是两者叠加:先用RAG保证答案来自最新、最准确的资料;如果还希望输出风格统一,比如客服语气更温和、能按固定模板汇报,再考虑对一个较小的模型微调。费用和改动频率是判断指标:内容频繁变化、答案必须给出处,选RAG;要求输出风格稳定、术语统一,再谈微调。文档问答这个场景,绝大多数情况下RAG是主力。
2. 知识库构建细节:检索效果的上游决定因素
RAG系统的答案质量,其实在检索之前就定了一半。工程里有个反复被验证的规律:如果召回的片段本身就答非所问,提示词写得再漂亮也救不回来。知识库构建需要处理好三件事:文档解析、文本分块、向量化入库。
2.1 文档接入前的统一化处理
第一步是把PDF、Word、Markdown、Excel统一成干净文本。这一步看起来简单,坑却最多。PDF最常见的问题是“复制出来的文本夹杂页眉页脚和乱码”;Word里的表格转成纯文本后结构经常丢;扫描版PDF就更麻烦,解析器拿出来的是空字符串,必须加OCR兜底,否则整份文档直接变成检索盲区。
我在第一版里直接套通用PDF解析,结果一份扫描合同抽出来的文本几乎为空。后来加了判断:解析出的文本长度太短,就自动切换到图像渲染加OCR。清洗环节也要做:去掉页眉页脚、把连续换行缩成空行、去掉异常字符。处理完的文本尽量保留标题层级,因为下一节的分块会依赖它。这一步的结果直接决定召回质量,千万别跳。
import os import re def extract_text(file_path: str) -> str: ext = os.path.splitext(file_path)[-1].lower() if ext == ".pdf": text = parse_pdf(file_path) # 具体解析由底层实现 elif ext in (".docx", ".doc"): text = parse_word(file_path) elif ext == ".md": text = open(file_path, encoding="utf-8").read() else: text = "" if len(text.strip()) < 200: # 触发 OCR 兜底 text = ocr_file(file_path) text = remove_headers_footers(text) text = re.sub(r"[ \t]+", " ", text) return re.sub(r"\n{3,}", "\n\n", text)OCR识别会产生错别字,这部分噪音会影响向量匹配。大批量导入前,最好先抽十几份样本文档人工检查清洗规则,确认没问题再全量跑。别等项目上线了才发现整批资料的文本是脏的,返工成本很高。
2.2 分块是检索的灵魂:尺寸、重叠和结构
文档切片直接决定搜索引擎命中什么。如果一块太大,例如整章扔进一条向量,信息会被稀释,语义相似度趋向平滑;如果一块太小,只有一两句话,检索到的片段缺少上下文,模型拿着碎片也答不利索。最常用的切分参数是200~500个token,相邻块之间保留10%~15%的重叠。重叠的意义是防止关键句被切在边界——两个chunk各存半个句子,检索时哪半条都不像完整答案。
更成熟的切分原则是“先结构,后长度”。Markdown先按标题层级切,PDF按章节和自然段切,表格按行保留,别把跨页表格拆散。切出来的每个块都要带元数据:来源文件名、章节路径、页码、块序号。这些字段在后面的引用溯源和权限过滤里很有用。
def split_document(text: str, max_chars: int = 600, overlap: int = 60): sections = split_by_heading(text) # 按标题 / 章节语义拆分 blocks = [] for sec in sections: start = 0 while start < len(sec): end = min(start + max_chars, len(sec)) blocks.append({ "text": sec[start:end], "heading": sec.heading, "page": sec.page, }) start = end - overlap return blocks这里我没有写死token数,因为不同分词器对中文的切法差别很大。用字符数做近似,再叠加结构优先,在中文场景下比纯token硬切直观得多,效果也更稳。还有两类内容需要特殊保护:代码块和表格,切分时不要从中间切断,宁可块长度略超,也要保持内聚。
2.3 向量化与向量库选型
分块之后做向量化,embedding模型的选择有讲究。用户提问通常是十几字的短句,文档片段是几百字的段落,两边的文本长度差很大,如果让同一个模型去编码,天然会有不对称匹配的问题。我的做法是优先选一个中英文和长短文都能兼顾的多语言embedding模型,然后拿几十条真实问题做评测,选“召回正确率”最高的那个,而不是只看模型榜单得分。
向量库选型更要看数据规模。几十份文档,用内存型向量索引跑Demo最省事;几千份文档,用关系数据库的向量插件,查询和业务元数据放在同一个库里,权限过滤好实现;到了百万级,才需要考虑独立向量数据库、分片和分布式。后端选型时还有一个隐藏指标:能不能支持元数据过滤。假设用户A只能看部门一的资料,向量库如果能在检索阶段直接过滤,性能和安全性都会强很多;不支持的话,应用层硬筛会吃掉大量召回性能,也更容易漏掉权限边界。
| 维度 | 小型Demo | 中小规模内部系统 | 百万级生产 |
|---|---|---|---|
| 文档量 | 少于200份 | 几千份 | 百万级 |
| 存储选型 | 内存向量索引 | 关系数据库向量插件 | 独立向量数据库 |
| 元数据过滤 | 应用层过滤 | 库级过滤 | 分布式过滤 |
| 主要成本 | 开发成本 | 运维成本 | 硬件与分片成本 |
走到这一步,知识库已经具备“上传—切块—向量入库—语义检索”的全部能力。接下来就是把这些能力包成稳定、可调用的服务接口。
3. FastAPI服务层搭建:从上传文件到返回答案
3.1 为什么选FastAPI
如果只想写一个小脚本,怎么都行;但要接前端、扛并发、留接口文档,FastAPI的优势就出来了。文档上传、文档解析、调用大模型,全是IO密集型操作,FastAPI原生的异步支持能明显提升并发吞吐;Pydantic模型天然兼任请求参数校验,字段写错前端立刻能看到明确报错;FastAPI还会自动生成交互式接口文档,前端联调时直接拿链接打开,不用反复发消息问“这个参数传什么”。第一次用就能在一个下午把上传、问答、查询三个接口跑通。
依赖只需要三个核心组件:FastAPI本体、ASGI服务、以及上传文件的multipart支持。我习惯用一条命令启动开发服务:
uvicorn main:app --host 0.0.0.0 --port 8000 --reload3.2 接口规划:上传、问答、状态、健康检查
我最终落地了四个接口,每个都有自己的目的:
| 接口 | 方法 | 功能 | 核心参数 |
|---|---|---|---|
| /upload | POST | 上传PDF/Word/Markdown并异步入库 | file, doc_id |
| /ask | POST | 提问,流式返回答案与引用 | question, top_k, user_id |
| /documents/{doc_id} | GET | 查询文档处理状态 | doc_id |
| /health | GET | 服务健康检查 | - |
上传接口设计成异步任务,因为文档解析和向量化可能耗时几十秒,不应该阻塞请求;问答接口设计成SSE流式返回,因为大模型首字延迟通常有几秒,如果前端花四秒干等着一个JSON,用户早就怀疑系统崩了。
3.3 上传接口的实现要点
上传这块有两个容易忽略的点:临时文件管理和失败重跑。多人同时上传时,同名文件会互相覆盖,我习惯用uuid生成唯一目录再保存原始文件。文档处理失败不能直接扔一个500给用户,而是把任务状态写进数据库,让用户通过状态接口看到“解析失败+原因”,排障会高效很多。
from fastapi import FastAPI, File, UploadFile, BackgroundTasks from uuid import uuid4 app = FastAPI(title="文档问答服务", version="0.1.0") @app.post("/upload") async def upload_document( background_tasks: BackgroundTasks, file: UploadFile = File(...), ): doc_id = uuid4().hex save_path = save_to_temp(file, doc_id) background_tasks.add_task(ingest_document, doc_id, save_path) return {"status": "processing", "doc_id": doc_id}关键点在后一行。ingest_document放到后台任务,接口才能毫秒级返回;如果同步执行,文件上传接口会被钉死在解析流程上,前端体验极差。代码跑得久以后还会发现另一个问题:服务重启会让尚未处理完的任务丢失。所以状态表里要记录“处理中”的任务,启动时扫描一次,把没做完的重新入队。这个细节在宕机恢复场景下很关键。
3.4 问答接口与SSE流式返回
问答流程是标准的五步:接收问题、问题向量化、向量库检索top_k、组装提示词、调大模型流式生成。FastAPI返回SSE事件流也不复杂,只需返回一个StreamingResponse。生成完text块之后,再把引用清单作为最后一个事件推出去,前端拿到以后渲染在答案下方。
from fastapi.responses import StreamingResponse @app.post("/ask") async def ask_question(req: AskRequest): async def event_stream(): chunks = await retrieve_passages(req.question, top_k=req.top_k) prompt = build_prompt(req.question, chunks) async for text_chunk in stream_llm(prompt): yield f"data: {json.dumps({'type': 'text', 'content': text_chunk})}\n\n" yield f"data: {json.dumps({'type': 'sources', 'content': chunks_meta})}\n\n" return StreamingResponse(event_stream(), media_type="text/event-stream")流式返回还有一个隐藏优势:用户可以提前感知回答在生成,而不是盯着一个转圈图标发呆。对内部工具来说,这种“有反馈感”的交互体验对最终用户接受度提升很大。要注意的是,前端断开连接时,后端要能取消生成子协程,避免服务器空耗。这个可以通过监听断开事件或者依赖ASGI的超时机制来做。
4. 答案生成与质量调优:提示词、参数和引用
4.1 给模型立规矩:四段式提示词模板
检索做得再准,提示词写得含糊,模型照样自由发挥。我常用的提示词模板分四段:角色、材料、任务、底线。角色告诉它“你是一个企业文档问答助手”;材料区用明确标签包住检索片段;任务区要求只能依据材料回答;底线区规定“没有相关信息就直说,不许编”。
你是一位企业文档问答助手。只能根据下列材料回答用户问题,不得使用材料以外的知识。 材料: <material> {context} </material> 用户问题:{question} 要求: 1. 如果材料中包含答案,请用简洁语言分点回答。 2. 如果材料中没有明确信息,请直接回答“资料中未找到相关信息”。 3. 不要编造数据,不要补充材料外细节。“不许编”这句话看起来简单,实际对生成结果影响很大。没有这句约束时,模型很容易在材料信息不足的地方用训练记忆补全;加上之后,至少它会学着说“没找到”。如果想让答案更结构化,可以在模板里追加输出格式要求,比如“用1、2、3分点阐述,每个要点控制在两行以内”。
4.2 三个参数的实操调优
模型参数很多人都是默认一把梭,但在文档问答里,temperature必须压低。我试过temperature=0.7,回答语气飘,偶尔加入原材料里没有的观点;调到0.1~0.2后,幻觉现象明显减少。top_p可以稳定在0.9附近,max_tokens根据答案长度预留,但不要给得太大,否则模型容易在回答完成后补一段无意义的“温馨提示”。多轮追问场景下,历史消息不能无限塞,一般保留最近两三轮,超过就裁剪,否则提示词越攒越长,最终超出上下文窗口。
4.3 让答案“可验证”:引用与溯源
这是RAG区别于裸聊大模型的重要特征。系统返回的source列表里包含文档名、页码、原文片段。实现上很简单:检索阶段已经拿到了chunk及元数据,把元数据原样塞进返回结构即可。提示词里可以要求模型在生成时给观点标上引用序号(比如[1]、[2]),输出后再把序号映射回真实元数据,前端把序号渲染成可点击的超链,点一下就直接跳到原文。这个功能极大提升用户对系统的信任感。内部系统尤其看重这个能力,毕竟没人敢拿着一个“说不出来源”的回答去汇报工作。
5. 常见问题与排查技巧实录
5.1 “答非所问”先查检索,不调提示词
拿到一个badcase,我的排查顺序永远是先看检索结果。把接口里实际命中的片段打印出来,检查它是不是用户问题对应的那个段落。如果检索结果本身就跑偏了,提示词再怎么优化也没用。常见原因有三种:分块太长导致语义稀释,embedding模型对专有名词不敏感,以及用户用词和文档原文对不上(比如用户说“薪资”,文档写“薪酬”)。对策分别是缩小分块、换更强的多语言embedding、在检索阶段增加关键词权重或混合检索。
5.2 文档导入慢、内存暴涨
导入大PDF时直接整份读入再解析,内存经常飙到几百兆,处理完还不释放,最后服务被系统杀掉。后来改成逐页流式解析,每页切块入库后及时释放对象;同时用信号量限制同时处理的上传任务数量,最多并发处理三份文档。还有个隐蔽问题:解析对象被全局列表持有,导致内存只涨不降。检查一下是不是把所有chunk都堆在内存里等“最后统一入库”,应该边处理边写库。
5.3 高并发下的大模型调用超时
大模型API和向量库都在服务外部时,连接池和超时是必配项。默认连接池太小,并发稍高就开始排队;我给请求设置了连接超时和读超时,失败时带退避重试。异步接口里更要克制使用同步阻塞库,比如某些PDF解析库是CPU密集型同步实现,直接放在事件循环里会拖垮整个服务,正确的做法是丢进线程池执行。这个问题的症状很典型:单个请求正常,并发一上来所有接口一起变慢。
5.4 输出格式失控、JSON解析失败
如果要求模型输出JSON,在提示词里加一句“只输出JSON,不要任何解释”,但依然有概率失败。我的兜底方案是:先尝试json.loads,失败后用正则截取大括号内的内容再解析一次;还不行就用低温度重试一次。生产环境一定要把模型原始输出原样写进日志,否则前端解析失败时,你连原始文本都看不到了,排查只能靠猜。
5.5 权限隔离:不能让人看到不该看的文档
文档问答最容易翻车的场景是权限。如果所有资料放进同一个向量库,检索时不带用户身份,那等于全员可查。我的做法是在检索参数里增加元数据过滤,例如用user_id去匹配文档的授权部门列表。这个过滤必须发生在检索阶段,而不是生成之后。生成阶段才做权限处理,模型已经“看”过不该看的内容,安全边界就失效了。这是上线前必须确认的事情,不是上线后再补的功能。
6. 从Demo到上线:评测、观察和后续迭代
6.1 先建评测集,再谈调优
没有评测集,系统好不好全凭感觉。我建了一份约30题的黄金问题集,覆盖三类:直接有答案、跨文档概括、资料中没有相关内容。每条问题都预先写好理想答案和应命中文档。以后任何改动,都用这份题跑一遍,统计检索命中率、回答忠实度、引用正确率三个指标。不迷信一次性直觉,数据会告诉你真正的瓶颈在检索还是在生成。
6.2 全链路日志是排障利器
我在每个环节都打日志:收到问题、检索到哪些chunk、模型生成耗时、返回来源。线上排查时,只要拉出一条请求链路,就能判断卡在检索还是卡在生成。同时在前端放一个反馈按钮,把负反馈和当时的问题绑定,定期导出badcase,补进评测集。这个闭环看起来简单,很多项目却都没做,最后永远在拍脑袋改参数,改完也不知道有没有变好。
6.3 下一步还能怎么扩展
系统稳定运行后,再考虑增强方向:混合检索(向量+关键词)补足专有名词漏召回;在检索结果上加重排序模型,让最相关的片段排到前面;多文档深度问答把零散答案组织成结构化汇总;增量更新需要做好文档去重和归档。这些方向没有一个需要推翻现有架构,FastAPI+向量库+大模型接口的骨架仍然成立,迭代成本很低。
我个人做完这套系统最大的收获是:大模型应用里最值钱的不是模型本身,而是围绕数据构建的可控流程。RAG把知识获取和语言生成解耦,FastAPI把整套能力封装成规范接口,只要检索质量抓好、权限边界卡严、评测闭环跑起来,这套架构确实能稳定地上线运行。希望这篇构建复盘能给你一个完整的认知坐标,少走我踩过的这些弯路。