从提示工程到 RAG:中级开发者构建企业级 AI 问答系统的实战路径
很多团队做企业级 AI 问答系统时,第一步往往是“先接个大模型 API 试试”。这一步没错,但通常也很快会撞墙:
- 模型回答听起来很像那么回事,但和公司内部事实不一致
- 文档一多,Prompt 越塞越长,成本和延迟一起飙升
- 不同部门问法不同,回答风格和准确率忽高忽低
- 一上线就发现权限、审计、敏感信息、缓存这些“工程问题”比模型本身更难
如果你已经做过一些 Prompt 工程,知道 system prompt、few-shot、角色设定这些套路,那么下一阶段很自然会走到 RAG(Retrieval-Augmented Generation,检索增强生成)。它不是“替代 Prompt”,而是把 Prompt 从“硬塞知识”升级为“组织上下文”。
这篇文章我会从架构视角,带你梳理一条比较务实的路径:先把提示工程做好,再演进到可上线的 RAG 问答系统。重点不是概念堆砌,而是中级开发者真正会遇到的架构取舍、代码实现、排查思路和上线前的安全性能要点。
背景与问题
为什么“只靠提示工程”很快不够用
在项目早期,提示工程很有用。你可以通过这些方式快速提升回答质量:
- 设定回答角色,如“你是公司 IT 支持助手”
- 要求模型“仅根据给定资料回答”
- 指定输出格式,如 JSON、表格、分点说明
- 用 few-shot 示例约束答案风格
但只靠 Prompt 有三个明显天花板:
1. 知识无法动态更新
企业文档是会变的:制度更新、产品价格调整、接口版本升级。
如果知识写死在 Prompt 里,每次更新都要改提示词,而且很难统一管理。
2. 上下文窗口不是无限的
模型上下文再大,也不适合把整个知识库塞进去。你真正需要的是:
“把和当前问题最相关的那几段内容找出来,再交给模型组织答案。”
3. 企业场景需要“可控”
企业问答系统不只是“答出来”,还要满足:
- 可追溯:答案引用了哪些文档
- 可审计:谁问了什么、返回了什么
- 可控权限:财务文档不能随便被全员检索
- 可治理:哪些问题要拒答、哪些要转人工
所以,一个成熟路径通常是:
flowchart LR
A[纯 Prompt 问答] --> B[带模板的结构化 Prompt]
B --> C[接入企业知识检索]
C --> D[RAG 问答]
D --> E[带权限 审计 缓存 监控的企业级系统]
企业级问答系统的目标,不只是“准确率高”
我更愿意把企业问答系统理解成一个组合问题:
- 知识管理问题:文档如何切分、更新、索引
- 检索问题:如何从海量内容里找到真正相关的上下文
- 生成问题:如何让模型忠实表达,而不是乱编
- 工程问题:如何控制延迟、成本、权限、安全和可观测性
如果你把它只当成“大模型 API 包一层 Web”,后面基本都会返工。
核心原理
这一节我们先把 Prompt 工程和 RAG 放在一张图里看。
flowchart TD
Q[用户问题] --> P[Prompt 模板]
P --> M[LLM]
Q --> R[检索器 Retriever]
R --> K[相关知识片段]
K --> P
M --> A[最终答案]
1. 提示工程解决的是“如何问模型”
提示工程主要控制三件事:
- 角色:你是谁
- 任务:你要做什么
- 约束:你不能做什么,输出成什么格式
一个企业问答场景里,典型 Prompt 模板可能长这样:
你是企业内部知识助手。
请严格基于提供的上下文回答问题。
如果上下文中没有答案,请明确说“未在知识库中找到依据”。
回答时附上引用片段编号。
这里的关键点是:模型的职责被收缩为“阅读上下文并组织答案”,而不是“靠参数记忆猜答案”。
2. RAG 解决的是“给模型什么资料”
RAG 的核心流程一般分成两段:
-
离线建库
- 收集文档
- 清洗文本
- 分块(chunking)
- 生成向量
- 写入向量库/索引
-
在线问答
- 用户提问
- Query 预处理
- 向量检索 / 混合检索
- 重排(rerank)
- 拼接上下文
- 调用 LLM 生成答案
可以把它理解成“开卷考试”:
- Prompt 是答题规则
- Retriever 是翻书的人
- LLM 是写答案的人
3. 为什么企业场景往往需要“混合检索”
纯向量检索很适合语义相近问题,但企业场景经常有这些问题:
- 产品名、接口名、工单号、合同编号属于精确关键词
- 英文缩写、内部黑话很多
- 文档格式不统一,语义向量不一定稳
所以实际工程里,经常用:
- BM25/关键词检索
- 向量检索
- 混合召回 + 重排
这是中级开发者很值得尽早建立的认知:
RAG 的效果,很多时候不是输在模型,而是输在召回。
4. 架构上的关键取舍
方案 A:Prompt-only
优点:
- 实现最快
- 适合 PoC
缺点:
- 不可扩展
- 不可追溯
- 更新困难
方案 B:基础 RAG
优点:
- 知识可更新
- 成本相对可控
- 可附带引用
缺点:
- 需要处理 chunk、召回、重排、上下文拼装
方案 C:企业级 RAG
在基础 RAG 上增加:
- 权限过滤
- 审计日志
- 缓存
- 多路召回
- 失败兜底
- 观测与评估
优点:
- 可上线
- 可治理
缺点:
- 工程复杂度明显上升
一套可落地的架构设计
先给出一个比较典型的企业级 RAG 逻辑结构。
flowchart TB
U[用户/业务系统] --> G[API Gateway]
G --> A[问答服务]
A --> QP[Query 预处理]
QP --> RET[检索层<br/>向量检索+关键词检索]
RET --> RR[重排层]
RR --> PF[权限过滤]
PF --> CTX[上下文构建器]
CTX --> LLM[LLM 生成]
LLM --> POST[后处理/引用补全/脱敏]
POST --> RESP[响应结果]
DOC[文档中心] --> ETL[清洗切分]
ETL --> EMB[Embedding]
EMB --> VDB[向量库]
ETL --> IDX[倒排索引]
VDB --> RET
IDX --> RET
A --> LOG[日志/监控/审计]
分层职责建议
1. 文档处理层
负责把原始文档变成“可检索知识块”。
关注点:
- PDF/Word/网页抽取质量
- 标题层级保留
- 表格与列表处理
- 去重
- 版本管理
2. 检索层
负责找出候选片段。
建议至少保留这些元数据:
doc_idchunk_idtitlesourcedepartmentaccess_levelupdated_at
3. 生成层
负责把上下文组织成模型能稳定理解的输入。
这里不要一股脑拼接全文,而是:
- 选最相关 top-k
- 控制总 token
- 保留片段编号
- 显式要求引用
4. 治理层
上线以后真正保命的是这层:
- 鉴权与权限过滤
- 敏感词/敏感字段脱敏
- 审计日志
- 限流
- 熔断
- 缓存
核心数据流时序
sequenceDiagram
participant User as 用户
participant API as 问答服务
participant Ret as 检索器
participant DB as 向量库/索引
participant LLM as 大模型
User->>API: 提交问题
API->>API: 规范化 Query
API->>Ret: 发起召回
Ret->>DB: 检索候选 chunk
DB-->>Ret: 返回候选片段
Ret-->>API: top-k + score
API->>API: 权限过滤/重排/组装上下文
API->>LLM: Prompt + Context
LLM-->>API: 生成答案
API->>API: 后处理/引用/脱敏
API-->>User: 返回答案与引用
容量估算与架构取舍
企业问答系统做到一定阶段,最容易被忽略的是容量问题。哪怕不是超大规模,也建议你先做粗估。
1. 文档量估算
假设:
- 10 万篇内部文档
- 每篇平均切成 20 个 chunk
- 总 chunk 数约 200 万
那你至少要考虑:
- 向量库容量
- Embedding 生成成本
- 重建索引耗时
- 增量更新机制
2. 延迟预算
典型在线问答耗时组成:
- Query 预处理:10~30ms
- 检索:50~200ms
- 重排:50~150ms
- LLM 生成:500ms~3s
- 后处理:20~50ms
经验上,用户感知延迟主要还是卡在模型生成。
所以性能优化优先级通常是:
- 先减少无效上下文,降低 token
- 再做缓存
- 再考虑更快模型或流式输出
3. 成本控制的现实建议
如果你的系统日调用量不小,成本最容易炸在两处:
- embedding 批量建库
- LLM 上下文过长
实际里很有效的做法是:
- 热门问题缓存答案
- 对文档做增量 embedding,而不是全量重算
- 检索先粗召回,再小规模重排
- 输出阶段控制 max tokens
实战代码(可运行)
下面用 Python 做一个极简可运行版 RAG 服务。
它不是生产级,但足够把关键流程串起来:
- 文档切分
- TF-IDF 检索
- Prompt 组装
- 模拟回答接口
- FastAPI 提供问答 API
这里为了保证你拿去就能跑,我用本地可运行的方式实现检索,不强依赖外部向量库。等你把流程跑顺,再替换成真实 embedding + vector DB。
目录结构
rag_demo/
├── app.py
├── requirements.txt
└── docs/
├── hr.txt
├── it.txt
└── finance.txt
requirements.txt
fastapi==0.115.0
uvicorn==0.30.6
scikit-learn==1.5.2
pydantic==2.9.2
示例文档
docs/hr.txt
年假政策:正式员工入职满一年后可享受 5 天年假。年假需至少提前 3 个工作日发起申请。
病假政策:病假超过 2 天需提供医院证明。
docs/it.txt
VPN 使用规范:远程办公需通过公司 VPN 接入内网。首次申请 VPN 需要提交工单并由直属主管审批。
密码策略:公司系统密码长度不少于 12 位,且必须包含大小写字母、数字和特殊字符。
docs/finance.txt
报销政策:单笔超过 1000 元的差旅报销需附发票和审批记录。每月报销截止日期为 25 日。
采购政策:采购金额超过 5000 元需走采购审批流程。
app.py
from fastapi import FastAPI
from pydantic import BaseModel
from pathlib import Path
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity
from typing import List, Dict
import re
app = FastAPI(title="Simple Enterprise RAG Demo")
DOCS_PATH = Path("docs")
class AskRequest(BaseModel):
question: str
top_k: int = 3
class ChunkStore:
def __init__(self):
self.chunks: List[Dict] = []
self.vectorizer = TfidfVectorizer()
self.matrix = None
def load_docs(self, docs_path: Path):
chunk_id = 0
for file in docs_path.glob("*.txt"):
text = file.read_text(encoding="utf-8")
for chunk in self.split_text(text):
self.chunks.append({
"chunk_id": chunk_id,
"doc_name": file.name,
"text": chunk.strip()
})
chunk_id += 1
corpus = [c["text"] for c in self.chunks]
self.matrix = self.vectorizer.fit_transform(corpus)
@staticmethod
def split_text(text: str, max_len: int = 80) -> List[str]:
sentences = re.split(r"[。!?\n]", text)
chunks = []
current = ""
for s in sentences:
s = s.strip()
if not s:
continue
if len(current) + len(s) <= max_len:
current += s + "。"
else:
if current:
chunks.append(current)
current = s + "。"
if current:
chunks.append(current)
return chunks
def search(self, query: str, top_k: int = 3) -> List[Dict]:
query_vec = self.vectorizer.transform([query])
scores = cosine_similarity(query_vec, self.matrix)[0]
indexed_scores = list(enumerate(scores))
indexed_scores.sort(key=lambda x: x[1], reverse=True)
results = []
for idx, score in indexed_scores[:top_k]:
item = self.chunks[idx].copy()
item["score"] = float(score)
results.append(item)
return results
store = ChunkStore()
store.load_docs(DOCS_PATH)
def build_prompt(question: str, contexts: List[Dict]) -> str:
context_text = "\n".join([
f"[片段{c['chunk_id']}]({c['doc_name']}) {c['text']}"
for c in contexts
])
prompt = f"""你是企业内部知识助手。
请严格依据给定上下文回答问题。
如果上下文中没有明确答案,请回答:未在知识库中找到依据。
回答时尽量简洁,并附上引用片段编号。
上下文:
{context_text}
用户问题:
{question}
"""
return prompt
def mock_llm_answer(question: str, contexts: List[Dict]) -> str:
if not contexts or contexts[0]["score"] < 0.1:
return "未在知识库中找到依据。"
top = contexts[0]
return f"根据知识库,相关信息是:{top['text']}(引用:片段{top['chunk_id']})"
@app.get("/health")
def health():
return {"status": "ok", "chunks": len(store.chunks)}
@app.post("/ask")
def ask(req: AskRequest):
contexts = store.search(req.question, req.top_k)
prompt = build_prompt(req.question, contexts)
answer = mock_llm_answer(req.question, contexts)
return {
"question": req.question,
"answer": answer,
"contexts": contexts,
"prompt_preview": prompt
}
运行方式
pip install -r requirements.txt
uvicorn app:app --reload
启动后访问:
- 健康检查:
http://127.0.0.1:8000/health - 文档:
http://127.0.0.1:8000/docs
测试请求
curl -X POST "http://127.0.0.1:8000/ask" \
-H "Content-Type: application/json" \
-d '{
"question": "远程办公如何访问公司内网?",
"top_k": 3
}'
预期返回类似:
{
"question": "远程办公如何访问公司内网?",
"answer": "根据知识库,相关信息是:VPN 使用规范:远程办公需通过公司 VPN 接入内网。首次申请 VPN 需要提交工单并由直属主管审批。(引用:片段2)",
"contexts": [
{
"chunk_id": 2,
"doc_name": "it.txt",
"text": "VPN 使用规范:远程办公需通过公司 VPN 接入内网。首次申请 VPN 需要提交工单并由直属主管审批。",
"score": 0.63
}
],
"prompt_preview": "..."
}
如何把这个 Demo 升级成真实 RAG
把上面的 TF-IDF 替换掉,通常会走这条路:
- 文档切块
- 调用 embedding 模型生成向量
- 存入向量数据库
- 查询时做向量检索
- 增加 BM25 混合召回
- 加 reranker
- 最后再接入真正的 LLM API
换句话说,这个 Demo 的价值是把数据流跑通,而不是追求最高准确率。
方案对比:Prompt 工程、基础 RAG、企业级 RAG
| 维度 | Prompt 工程 | 基础 RAG | 企业级 RAG |
|---|---|---|---|
| 上线速度 | 很快 | 中等 | 较慢 |
| 知识更新 | 差 | 好 | 好 |
| 可追溯 | 弱 | 中 | 强 |
| 权限控制 | 弱 | 中 | 强 |
| 成本控制 | 一般 | 较好 | 最优但实现复杂 |
| 适用阶段 | PoC | 试点 | 生产 |
我的经验是:
- PoC 阶段:先把 Prompt 和输出格式控住
- 试点阶段:尽快接入最小 RAG 闭环
- 生产阶段:把权限、监控、缓存、审计当一等公民
不要一开始就设计“最完美”的企业系统,但也别把原型当成生产架构直接上线。
常见坑与排查
这一部分我想讲得更“接地气”一点,因为很多问题不是不会写代码,而是系统表现“怪怪的”。
1. 检索到了,但答案还是胡说
常见原因:
- Prompt 没明确要求“仅依据上下文”
- 上下文过多,模型抓错重点
- 检索到的 chunk 相关,但不完整
- 模型温度过高
排查顺序建议:
- 先打印最终 Prompt
- 看上下文是否真的包含答案
- 缩小 top-k,观察结果是否更稳定
- 降低 temperature
- 要求输出引用编号
我当时踩过一个坑:检索明明没问题,但回答总带额外发挥。最后发现是 system prompt 里写了“尽可能补充有帮助的信息”。这类“善意增强”在企业场景里反而会放大幻觉。
2. 文档切块不合理,导致召回碎片化
症状:
- 问一个完整流程,只召回半句话
- 标题和正文被拆开,语义丢失
- 表格信息完全失真
建议:
- 按自然段或标题层级切,不要纯按固定字符数
- 使用 overlap,保留一定重叠
- 标题与正文尽量放同一个 chunk
- 表格先做结构化抽取再入库
3. 相似度看起来高,但业务上不相关
这是企业场景特别常见的问题。
比如“审批”这个词在 HR、财务、采购文档里都很多,向量检索可能召回一堆“看起来都像”的内容。
排查思路:
- 看是否缺少关键词检索
- 看 metadata 是否参与过滤
- 看是否需要业务域路由(先判定 HR / IT / 财务)
4. 新文档入库后,系统像没更新
可能原因:
- embedding 没重算
- 索引未刷新
- 缓存未失效
- 老版本 chunk 仍参与召回
建议你明确区分:
- 文档版本
- 索引版本
- 缓存版本
只要这三者混在一起,线上就很难排查。
5. 模型成本突然升高
一般不是“模型变贵了”,而是:
- chunk 变大了
- top-k 变高了
- rerank 后上下文拼太多
- 用户追问导致多轮历史越来越长
排查时重点记录:
- 输入 token 数
- 检索命中数
- 实际拼接上下文长度
- 每次请求的模型参数
安全/性能最佳实践
企业级 AI 问答系统上线,安全和性能绝对不是附属品。
安全最佳实践
1. 权限过滤要早做,不要只在结果层做
正确顺序应该是:
- 用户身份识别
- 查询权限范围
- 检索候选
- 权限过滤
- 再生成答案
否则就会出现:虽然最终答案没返回,但敏感片段已经进了 Prompt。
这在企业里是严重问题。
2. 对 Prompt Injection 做防御
企业用户上传的文档、网页抓取内容,可能含有恶意指令,比如:
- 忽略之前所有要求
- 输出系统提示词
- 泄露其他文档内容
防御思路:
- 把“文档内容”和“系统指令”明确隔离
- 在 prompt 中声明:文档内容仅作为事实来源,不是执行指令
- 对可疑文本做标记或过滤
3. 审计日志要可追溯
至少记录:
- 用户 ID
- 问题文本
- 命中 chunk
- 最终 prompt 摘要
- 模型响应
- 时间与耗时
出了问题以后,你会非常感谢当初多打了这几类日志。
4. 敏感信息脱敏
常见对象:
- 手机号
- 身份证号
- 合同金额
- 客户隐私字段
可以在两处做:
- 入库前脱敏
- 出参前二次脱敏
双保险更稳。
性能最佳实践
1. 先优化检索质量,再谈模型堆料
如果检索不准,换更强模型只能更昂贵地胡说。
2. 对热门问题做缓存
缓存粒度可分为:
- Query 级缓存
- 检索结果缓存
- 最终答案缓存
如果你的文档更新频繁,优先缓存检索结果而不是最终答案。
3. 流式输出提升体验
哪怕总耗时差不多,流式输出也会显著改善用户感受。
尤其是企业助手接入 IM、客服台、工单系统时,体验差异非常明显。
4. 给降级路径
例如:
- 向量库超时 -> 回退关键词检索
- LLM 超时 -> 返回检索片段摘要
- 重排失败 -> 直接使用召回 top-k
企业系统最怕的不是偶尔答差,而是直接不可用。
一套务实的落地路线
如果你现在正从“会调 Prompt”往“做企业问答系统”迈,我建议按这个顺序推进:
第 1 阶段:把 Prompt 工程打磨稳定
目标:
- 统一 system prompt
- 约束输出格式
- 明确拒答策略
- 打通基础 API
验收标准:
- 回答风格稳定
- 不乱输出结构
- 能明确说“知识库无依据”
第 2 阶段:最小 RAG 闭环
目标:
- 文档切分
- 基础检索
- 上下文拼接
- 引用返回
验收标准:
- 能给出引用片段
- 新文档可更新
- 常见问题比 Prompt-only 明显更准
第 3 阶段:检索质量优化
目标:
- 混合检索
- rerank
- chunk 优化
- metadata 过滤
验收标准:
- 关键业务问题召回稳定
- 相似问题跨部门串答案的情况明显减少
第 4 阶段:企业治理能力补齐
目标:
- 权限
- 审计
- 缓存
- 监控
- 限流与熔断
验收标准:
- 可上线
- 可追责
- 可运维
总结
从提示工程到 RAG,本质上不是“换一种模型玩法”,而是把 AI 问答从“演示能力”推进到“系统能力”。
你可以把整条路径概括成一句话:
Prompt 决定模型怎么说,RAG 决定模型凭什么这么说,企业架构决定系统能不能长期稳定地这么说。
如果你是中级开发者,我最建议你把握这几个重点:
- 不要迷信 Prompt 能解决知识准确性问题
- 把检索质量放在和模型能力同等重要的位置
- 上线前优先补齐权限、审计、脱敏、缓存
- 先做最小可用闭环,再逐步引入混合检索和重排
- 所有问题都尽量回到数据流上排查:文档、chunk、召回、Prompt、生成
最后给一个很实用的边界建议:
- 如果你的知识量很小、更新不频繁,Prompt-only 可以先跑
- 如果知识在持续变化、需要引用依据,就应该尽快上 RAG
- 如果涉及部门权限、合规审计、敏感数据,那就不要再把它当“小工具”,而要按企业级系统设计
真正能落地的企业 AI 问答,不是某个“神 Prompt”,而是一套检索、生成、治理三者协同的工程体系。