跳转到内容
123xiao | 无名键客

《从提示工程到 RAG:中级开发者构建企业级 AI 问答系统的实战路径》

字数: 0 阅读时长: 1 分钟

从提示工程到 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 的核心流程一般分成两段:

  1. 离线建库

    • 收集文档
    • 清洗文本
    • 分块(chunking)
    • 生成向量
    • 写入向量库/索引
  2. 在线问答

    • 用户提问
    • 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_id
  • chunk_id
  • title
  • source
  • department
  • access_level
  • updated_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

经验上,用户感知延迟主要还是卡在模型生成
所以性能优化优先级通常是:

  1. 先减少无效上下文,降低 token
  2. 再做缓存
  3. 再考虑更快模型或流式输出

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 替换掉,通常会走这条路:

  1. 文档切块
  2. 调用 embedding 模型生成向量
  3. 存入向量数据库
  4. 查询时做向量检索
  5. 增加 BM25 混合召回
  6. 加 reranker
  7. 最后再接入真正的 LLM API

换句话说,这个 Demo 的价值是把数据流跑通,而不是追求最高准确率。


方案对比:Prompt 工程、基础 RAG、企业级 RAG

维度Prompt 工程基础 RAG企业级 RAG
上线速度很快中等较慢
知识更新
可追溯
权限控制
成本控制一般较好最优但实现复杂
适用阶段PoC试点生产

我的经验是:

  • PoC 阶段:先把 Prompt 和输出格式控住
  • 试点阶段:尽快接入最小 RAG 闭环
  • 生产阶段:把权限、监控、缓存、审计当一等公民

不要一开始就设计“最完美”的企业系统,但也别把原型当成生产架构直接上线。


常见坑与排查

这一部分我想讲得更“接地气”一点,因为很多问题不是不会写代码,而是系统表现“怪怪的”。

1. 检索到了,但答案还是胡说

常见原因:

  • Prompt 没明确要求“仅依据上下文”
  • 上下文过多,模型抓错重点
  • 检索到的 chunk 相关,但不完整
  • 模型温度过高

排查顺序建议:

  1. 先打印最终 Prompt
  2. 看上下文是否真的包含答案
  3. 缩小 top-k,观察结果是否更稳定
  4. 降低 temperature
  5. 要求输出引用编号

我当时踩过一个坑:检索明明没问题,但回答总带额外发挥。最后发现是 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 决定模型凭什么这么说,企业架构决定系统能不能长期稳定地这么说。

如果你是中级开发者,我最建议你把握这几个重点:

  1. 不要迷信 Prompt 能解决知识准确性问题
  2. 把检索质量放在和模型能力同等重要的位置
  3. 上线前优先补齐权限、审计、脱敏、缓存
  4. 先做最小可用闭环,再逐步引入混合检索和重排
  5. 所有问题都尽量回到数据流上排查:文档、chunk、召回、Prompt、生成

最后给一个很实用的边界建议:

  • 如果你的知识量很小、更新不频繁,Prompt-only 可以先跑
  • 如果知识在持续变化、需要引用依据,就应该尽快上 RAG
  • 如果涉及部门权限、合规审计、敏感数据,那就不要再把它当“小工具”,而要按企业级系统设计

真正能落地的企业 AI 问答,不是某个“神 Prompt”,而是一套检索、生成、治理三者协同的工程体系。


分享到:

上一篇
《从单体到高可用:基于 Kubernetes 的中型业务集群架构设计与故障切换实战》
下一篇
《Java Web 开发中基于 Spring Boot + Redis 实现高并发接口限流的实战方案》