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

《从 Prompt 到生产力:中级开发者实战构建基于大语言模型的企业知识库问答系统》

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

从 Prompt 到生产力:中级开发者实战构建基于大语言模型的企业知识库问答系统

很多团队第一次接触大语言模型时,往往从一个很“灵”的 Prompt 开始:把文档贴进去,问一句“这份制度里报销上限是多少?”,模型回答得像模像样,于是大家都很兴奋。

但一旦进入企业环境,问题马上变了:

  • 文档很多,根本塞不进上下文
  • 知识经常更新,Prompt 手工维护不可持续
  • 用户会追问,且问法很散
  • 回答需要“有依据”,不能一本正经地胡说
  • 权限、审计、延迟、成本,都会从“以后再说”变成“现在必须解决”

我自己在做这类系统时,最大的体感是:企业知识库问答系统,重点不在“会不会调模型”,而在“如何围绕模型搭一个可靠的知识服务架构”

这篇文章我会从架构视角,带你把一个“Prompt Demo”推进到可运行、可扩展、可排查的企业知识库问答系统。读者定位是中级开发者,所以我会默认你已经熟悉 API 调用、后端服务、数据库这些基本能力,重点放在系统设计、取舍和落地代码上。


背景与问题

企业知识库问答,表面上是“问答”,本质上是一个多阶段系统:

  1. 接入知识:文档来自 PDF、Word、Wiki、数据库、工单系统
  2. 解析与切片:把非结构化内容转成可检索的片段
  3. 召回与排序:从海量知识里找到最相关的上下文
  4. 生成答案:让大模型基于上下文回答
  5. 引用与审计:告诉用户“答案依据来自哪里”
  6. 反馈闭环:记录坏答案、召回失败、热门问题,持续优化

如果只靠 Prompt,把整份文档直接喂给模型,会遇到三个根本性问题:

1. 上下文窗口有限

即使模型上下文越来越大,也不是无限的。企业文档常常是几十万字的规章、接口文档、产品手册、售后 SOP,不可能每次都完整塞进去。

2. 幻觉不可接受

通用模型很擅长“补全”语言,但企业场景要的是“基于已有知识作答”。如果没有可靠检索,模型就会靠参数记忆和语言惯性瞎补。

3. 成本与延迟会失控

把大段上下文每次都传给模型,成本高、响应慢,而且随着知识规模增长会越来越糟。

所以,真正可落地的方案通常不是“纯 Prompt”,而是以 RAG(Retrieval-Augmented Generation,检索增强生成) 为核心,再叠加缓存、重排、权限、监控、评测等工程能力。


先给结论:一套适合中级团队落地的架构

如果你是中小规模团队,目标不是做一个学术最强系统,而是尽快做出稳定可用的企业知识问答,我建议采用下面这套分层架构:

flowchart LR
    A[知识源: PDF/Wiki/数据库/FAQ] --> B[解析清洗]
    B --> C[切片 Chunking]
    C --> D[向量化 Embedding]
    D --> E[向量库]
    C --> F[关键词索引 BM25]
    
    U[用户问题] --> Q[Query 预处理]
    Q --> G[混合检索]
    E --> G
    F --> G
    G --> R[重排 Re-rank]
    R --> P[Prompt 组装]
    P --> L[LLM 生成答案]
    L --> O[返回答案 + 引用来源]

这套方案的核心特点:

  • 离线处理知识,在线只做检索和生成
  • 向量检索 + 关键词检索混合使用
  • 生成前做重排,减少错误上下文进入模型
  • 答案附引用,便于用户信任和排查
  • 模块化,方便后续替换向量库、模型、Embedding 服务

如果再细化成运行时交互流程,大概是这样:

sequenceDiagram
    participant User as 用户
    participant API as 问答服务
    participant RET as 检索服务
    participant VDB as 向量库
    participant KDB as 关键词索引
    participant LLM as 大语言模型

    User->>API: 提问
    API->>RET: 查询改写/检索请求
    RET->>VDB: 向量召回 topK
    RET->>KDB: BM25 召回 topK
    VDB-->>RET: 候选片段
    KDB-->>RET: 候选片段
    RET->>RET: 融合排序/重排
    RET-->>API: 上下文片段
    API->>LLM: system + context + user question
    LLM-->>API: 结构化答案
    API-->>User: 答案 + 引用 + 置信提示

核心原理

这一节不讲太学术,只讲做系统时真正需要理解的几件事。

1. 为什么 RAG 比“堆 Prompt”更靠谱

Prompt 的作用是控制模型行为,比如:

  • 回答风格
  • 输出格式
  • 是否要求引用
  • 知识不足时如何拒答

但 Prompt 不能替代外部知识检索。企业知识每天都在变化,模型参数不会自动更新。所以我们需要先把相关知识找出来,再让模型“带卷开考”。

一个常见误区是:
“只要 Prompt 写得足够严格,模型就不会胡说。”

现实是,如果上下文里没有答案,模型就只能:

  • 模糊化表达
  • 混合旧知识和语义惯性
  • 一本正经地编

所以正确顺序是:

先提高检索命中率,再优化 Prompt 约束,再做结果评测。


2. 切片(Chunking)决定了知识是否“找得到”

知识库问答里,很多问题并不是模型不行,而是切片方式出了问题。

举个例子,一篇报销制度文档里有这样一段:

  • 差旅住宿标准
  • 餐补标准
  • 城市等级定义
  • 特殊审批规则

如果你一刀切成 2000 字大块,检索容易召回一大段无关内容;
如果切得太碎,比如每 50 字一片,语义又不完整,模型拿到上下文反而看不懂。

经验上,中级团队可以先用这个策略起步:

  • 按自然段或标题层级切分
  • 每片控制在 300~800 中文字
  • 相邻片段保留 50~120 字重叠
  • 给每片保留元数据:文档名、章节名、更新时间、权限标签

这会直接影响后续检索效果。


3. 向量检索不是万能的,混合检索更稳

向量检索适合处理语义相近但字面不同的问题,比如:

  • “员工离职手续怎么办”
  • “离职交接流程是什么”

这两个问法很像,但未必共享很多关键词。

不过企业文档还有另一类内容,对关键词特别敏感:

  • 错误码:ERR_1042
  • 接口名:CreateOrder
  • 产品型号:X200-Pro
  • 制度编号:HR-TRAVEL-2024-03

这类内容单靠向量,经常不稳定。所以实战中我更推荐:

  • 向量检索:召回语义相关片段
  • BM25/全文检索:召回关键词精准命中片段
  • 融合排序:合并两边结果
  • 重排模型:最后再挑最相关的前几段

这是“又稳又不太复杂”的路线。


4. 重排(Re-rank)往往比换更大的模型更值

很多团队一开始回答质量不好,第一反应是换更强的 LLM。
但我踩过一个典型坑:真正的问题不是生成不够强,而是喂给模型的上下文不够准。

重排的作用是:对初步召回的 1030 个候选片段,再做一次更精细的相关性排序,留下最适合回答问题的 35 个。

这通常带来两个好处:

  • 降低无关内容干扰
  • 减少 Prompt 长度,省钱且更快

5. 企业系统要“可拒答”,不是“尽量回答”

面向消费者的聊天产品,回答得顺一点可能更重要。
但企业知识问答系统更需要的是边界感:

  • 找不到依据时,应明确说“知识库未找到明确答案”
  • 回答应附带引用来源
  • 对低置信结果给出提示,而不是硬编结论

这会稍微牺牲“看起来聪明”的体验,但会显著提高实际可用性。


方案对比与取舍分析

在真正动手前,先看三类常见方案。

方案架构复杂度效果成本适用场景
纯 Prompt + 手工上下文低~中Demo、POC
向量 RAG中~高大多数知识库
混合检索 + 重排 + 引用中~高中~高企业生产环境

我的建议很明确:

  • 验证阶段:先做向量 RAG
  • 进入真实使用阶段:尽快补上混合检索、引用和日志
  • 规模扩大后:再考虑多路召回、查询改写、反馈学习、权限裁剪

不要一开始就追求“最先进架构”,但也不要停留在纯 Prompt Demo。


容量估算:上线前别忽略这件事

很多知识库项目不是死在模型效果,而是死在容量规划太随意。

假设:

  • 1 万篇文档
  • 每篇平均切成 20 个 chunk
  • 总 chunk 数约 20 万
  • 每个向量 1536 维,float32 存储约 6KB
  • 仅向量数据约 1.2GB
  • 再加元数据、索引、冗余,实际可能到 3~5GB

在线查询时,如果:

  • 每天 2 万次提问
  • 峰值 QPS 10~20
  • 每次召回 20 个候选,再重排 10 个
  • 最终送入模型 4 个片段

那么瓶颈通常出现在:

  1. 向量检索延迟
  2. 重排服务吞吐
  3. LLM 调用成本与尾延迟
  4. 文档更新后的增量索引速度

所以架构上建议一开始就把这些模块拆开,至少做到:

  • 检索服务独立
  • 生成服务独立
  • embedding/索引构建走异步任务
  • 日志和评测单独沉淀

实战代码(可运行)

下面给一个可运行的 Python 示例,演示一个简化版企业知识库问答系统。
为了便于你本地直接跑,我这里用:

  • sentence-transformers 生成向量
  • faiss-cpu 做向量检索
  • 一个简单的“伪 BM25”关键词召回
  • 可替换的 LLM 接口层

你可以先把检索链路跑通,再接自己的模型服务。

目录结构建议

kb_qa/
├── app.py
├── build_index.py
├── docs/
│   ├── travel_policy.txt
│   └── api_guide.txt
└── requirements.txt

安装依赖

pip install sentence-transformers faiss-cpu fastapi uvicorn numpy

示例文档

docs/travel_policy.txt

差旅报销制度

一、住宿标准
一线城市住宿上限为每晚 500 元,二线城市住宿上限为每晚 350 元。

二、餐补标准
出差期间每日餐补为 80 元,无需提供餐饮发票。

三、特殊审批
超过标准的费用需由部门负责人审批。

docs/api_guide.txt

订单接口文档

CreateOrder 接口用于创建订单。
请求方法为 POST。
当库存不足时返回错误码 ERR_1042。

构建索引脚本

build_index.py

import os
import json
import faiss
import numpy as np
from sentence_transformers import SentenceTransformer

DOCS_DIR = "docs"
INDEX_FILE = "faiss.index"
META_FILE = "meta.json"

model = SentenceTransformer("sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2")


def chunk_text(text, chunk_size=120, overlap=30):
    chunks = []
    start = 0
    while start < len(text):
        end = min(len(text), start + chunk_size)
        chunk = text[start:end].strip()
        if chunk:
            chunks.append(chunk)
        if end == len(text):
            break
        start = end - overlap
    return chunks


def load_documents():
    items = []
    for filename in os.listdir(DOCS_DIR):
        path = os.path.join(DOCS_DIR, filename)
        if not os.path.isfile(path):
            continue
        with open(path, "r", encoding="utf-8") as f:
            text = f.read()
        for i, chunk in enumerate(chunk_text(text)):
            items.append({
                "id": len(items),
                "doc_name": filename,
                "chunk_id": i,
                "text": chunk
            })
    return items


def build():
    items = load_documents()
    texts = [x["text"] for x in items]
    embeddings = model.encode(texts, normalize_embeddings=True)
    embeddings = np.array(embeddings).astype("float32")

    dim = embeddings.shape[1]
    index = faiss.IndexFlatIP(dim)
    index.add(embeddings)

    faiss.write_index(index, INDEX_FILE)

    with open(META_FILE, "w", encoding="utf-8") as f:
        json.dump(items, f, ensure_ascii=False, indent=2)

    print(f"Indexed {len(items)} chunks.")


if __name__ == "__main__":
    build()

运行:

python build_index.py

问答服务

app.py

import json
import faiss
import numpy as np
from fastapi import FastAPI
from pydantic import BaseModel
from sentence_transformers import SentenceTransformer

INDEX_FILE = "faiss.index"
META_FILE = "meta.json"

app = FastAPI()
model = SentenceTransformer("sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2")
index = faiss.read_index(INDEX_FILE)

with open(META_FILE, "r", encoding="utf-8") as f:
    metadata = json.load(f)


class Query(BaseModel):
    question: str
    top_k: int = 3


def keyword_score(question, text):
    q_tokens = [x for x in question.lower().split() if x.strip()]
    text_lower = text.lower()
    return sum(1 for token in q_tokens if token in text_lower)


def hybrid_search(question, top_k=3):
    q_emb = model.encode([question], normalize_embeddings=True)
    q_emb = np.array(q_emb).astype("float32")

    scores, ids = index.search(q_emb, 10)
    vector_candidates = []
    for score, idx in zip(scores[0], ids[0]):
        if idx == -1:
            continue
        item = metadata[idx]
        vector_candidates.append({
            "id": idx,
            "score_vector": float(score),
            "score_keyword": keyword_score(question, item["text"]),
            "doc_name": item["doc_name"],
            "text": item["text"]
        })

    # 简单融合排序:向量分 + 关键词分
    for item in vector_candidates:
        item["final_score"] = item["score_vector"] + 0.1 * item["score_keyword"]

    vector_candidates.sort(key=lambda x: x["final_score"], reverse=True)
    return vector_candidates[:top_k]


def generate_answer(question, contexts):
    joined_context = "\n\n".join(
        [f"[来源: {c['doc_name']}]\n{c['text']}" for c in contexts]
    )

    # 这里为了可运行,先做一个简单模板输出
    # 在生产环境中,你应替换为真实的 LLM API 调用
    answer = (
        "基于当前检索到的知识,给出如下回答:\n"
        f"问题:{question}\n\n"
        f"参考内容:\n{joined_context}\n\n"
        "如果你接入真实大语言模型,请在这里要求它:"
        "仅基于参考内容回答,无法确认时明确说明,并附上引用来源。"
    )
    return answer


@app.post("/ask")
def ask(query: Query):
    contexts = hybrid_search(query.question, query.top_k)
    answer = generate_answer(query.question, contexts)
    return {
        "question": query.question,
        "answer": answer,
        "sources": [
            {
                "doc_name": c["doc_name"],
                "text": c["text"],
                "score": c["final_score"]
            }
            for c in contexts
        ]
    }

启动服务:

uvicorn app:app --reload

测试请求:

curl -X POST "http://127.0.0.1:8000/ask" \
  -H "Content-Type: application/json" \
  -d '{
    "question": "一线城市出差住宿报销上限是多少?",
    "top_k": 3
  }'

接入真实 LLM 的 Prompt 模板

当你把 generate_answer 替换成真实模型调用时,建议先用这种保守型 Prompt:

def build_prompt(question, contexts):
    context_text = "\n\n".join([
        f"[文档: {c['doc_name']}]\n{c['text']}" for c in contexts
    ])

    system_prompt = """你是企业知识库问答助手。
请严格遵守以下规则:
1. 只能依据给定参考内容回答。
2. 如果参考内容不足以回答,明确说“知识库中未找到明确答案”。
3. 回答尽量简洁准确。
4. 回答末尾列出引用来源文档名。
"""

    user_prompt = f"""用户问题:
{question}

参考内容:
{context_text}

请给出答案。"""
    return system_prompt, user_prompt

如果你用的是 OpenAI 风格接口,大致会像这样:

import os
from openai import OpenAI

client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])

def call_llm(question, contexts):
    system_prompt, user_prompt = build_prompt(question, contexts)

    resp = client.chat.completions.create(
        model="gpt-4o-mini",
        temperature=0,
        messages=[
            {"role": "system", "content": system_prompt},
            {"role": "user", "content": user_prompt},
        ]
    )
    return resp.choices[0].message.content

几个实战建议:

  • temperature 尽量低,知识问答通常设为 00.2
  • 要求输出引用来源
  • 明确要求“依据不足时拒答”
  • 把“参考内容”与“用户问题”边界写清楚,减少 Prompt 注入风险

更接近生产环境的模块拆分

如果你的系统准备上线,我建议至少拆成下面几个服务:

classDiagram
    class IngestionService {
      +parse(file)
      +clean(text)
      +chunk(text)
      +embed(chunks)
      +index(chunks)
    }

    class RetrievalService {
      +rewrite(query)
      +vectorSearch(query)
      +keywordSearch(query)
      +rerank(candidates)
    }

    class QAService {
      +buildPrompt(contexts, query)
      +generateAnswer()
      +formatCitations()
    }

    class AuditService {
      +logQuery()
      +logSources()
      +collectFeedback()
    }

    IngestionService --> RetrievalService
    RetrievalService --> QAService
    QAService --> AuditService

这样拆的好处是:

  • 索引构建和在线问答解耦
  • 检索策略能独立迭代
  • 模型更换不影响文档处理链路
  • 日志、反馈、评测容易单独做

常见坑与排查

这部分我尽量写得“像真干过”,因为这些问题真的很常见。

1. 检索结果看起来相关,答案却还是错

现象

召回出来的文档“像是那个主题”,但真正回答问题的关键句没进来。

常见原因

  • chunk 太大,重点信息被淹没
  • chunk 太碎,语义不完整
  • top_k 太小,关键片段没召回
  • 重排缺失,真正相关内容排位太低

排查方式

先别急着怪模型,按顺序看:

  1. 原始 query 是什么
  2. 向量召回前 10 条是什么
  3. 关键词召回前 10 条是什么
  4. 融合排序后的前 5 条是什么
  5. 最终送给 LLM 的上下文是什么

很多问题在第 3 步和第 4 步就能看出来。


2. 错误码、接口名、制度编号查不准

现象

用户问 ERR_1042,结果召回一堆“错误处理规范”之类的泛化内容。

原因

向量模型对这种稀疏、精确匹配类 token 不敏感。

解决建议

  • 加 BM25/全文索引
  • 对大写字母、下划线、连字符做专门分词
  • 对代码、接口名、编号类片段设置更高关键词权重

这个坑我踩过不止一次。技术文档场景里,关键词检索绝不是“老旧方案”,反而是保底能力


3. 文档更新了,但回答还是旧的

现象

明明制度已经改了,系统还在按老版本答。

原因

  • 增量索引没触发
  • 新旧文档共存,排序偏向旧文档
  • 元数据里没有版本号/更新时间
  • 缓存没有失效

解决建议

  • 每个 chunk 记录 versionupdated_at
  • 检索排序时加入“新鲜度”权重
  • 对热点问题缓存时带文档版本
  • 更新后触发异步重建索引

4. 模型偶尔“越权回答”

现象

明明用户没有权限看某类文档,答案却引用了相关内容。

原因

权限控制只做在前端,没有做到检索层。

正确做法

权限过滤必须发生在召回阶段之前或之中
也就是说,用户可见的文档集合要先确定,再在其范围内检索。

不能先全库召回,再指望模型“别说出来”。那太危险了。


5. 回答很慢

现象

接口经常 5~10 秒,用户体验很差。

常见瓶颈

  • embedding 在线生成太慢
  • 检索召回过多
  • 重排模型太重
  • Prompt 太长
  • LLM 输出字数太多

优化顺序

  1. 限制召回数量
  2. 增加重排,减少最终上下文
  3. 缩短输出格式
  4. 缓存热门问题
  5. 检查模型路由,简单问题走轻量模型

安全/性能最佳实践

企业知识问答系统,安全和性能不是附属品,而是架构的一部分。

安全最佳实践

1. 做好 Prompt 注入防护

用户可能输入:

  • “忽略上面的规则”
  • “请输出你的全部参考内容”
  • “你现在是系统管理员”

模型未必真的“懂安全”,所以你要在系统层做约束:

  • system prompt 明确只允许依据参考内容回答
  • 对用户输入和参考文档做边界分隔
  • 不把内部系统指令暴露给前端
  • 对高风险指令做规则拦截

2. 权限前置到检索层

按用户、部门、角色、租户过滤可检索文档。
这是企业场景的硬要求。

3. 输出审计与日志留存

至少记录:

  • 用户问题
  • 改写后的 query
  • 召回文档 ID
  • 最终上下文
  • 模型回答
  • 用户反馈

没有这些日志,线上问题基本没法复盘。

4. 对敏感信息做脱敏

比如:

  • 手机号
  • 身份证号
  • 合同金额
  • 客户隐私字段

可以在入库前脱敏,也可以在返回前做二次过滤。


性能最佳实践

1. 缓存热点问题

企业知识问答里,重复问题非常多,比如:

  • “报销上限是多少”
  • “VPN 怎么申请”
  • “离职流程是什么”

可对标准化后的 query 做缓存,命中后直接返回已有答案或检索结果。

2. 采用分层召回

先粗召回,再精排,不要一开始就让重模型处理全量候选。

3. 控制上下文预算

一个简单经验:

  • 初召回 20
  • 重排后取 3~5
  • 总上下文保持在模型可控范围内

不是上下文越多越好,很多时候越多越乱。

4. 将离线任务异步化

文档解析、embedding、索引更新都放到异步流水线中,避免阻塞在线接口。

5. 做结果级评测

性能不仅是响应时间,也包括“有效回答率”。建议至少跟踪:

  • 检索命中率
  • 引用覆盖率
  • 拒答准确率
  • 用户满意度
  • 平均延迟
  • 单问成本

一个上线前检查清单

如果你已经有了一个能跑的版本,建议在上线前过一遍:

  • 文档切片是否保留标题、来源、更新时间
  • 是否支持混合检索
  • 是否有重排机制
  • 回答是否附引用
  • 找不到答案时是否会明确拒答
  • 权限是否在检索层生效
  • 是否记录召回和回答日志
  • 文档更新后是否支持增量索引
  • 是否有热门问题缓存
  • 是否能统计延迟、错误率和用户反馈

这份清单看起来朴素,但比“换更大的模型”更能决定系统能不能真正用起来。


总结

从 Prompt 到生产力,关键不是把提示词写得多花,而是把知识问答拆成一条可靠的工程链路:

  • 离线构建知识索引
  • 在线做混合检索与重排
  • 让模型只基于证据回答
  • 输出引用,支持拒答
  • 把权限、审计、缓存和评测补齐

如果你是中级开发者,我建议按下面的顺序推进,而不是一口气做“全家桶”:

  1. 先做一个最小可运行的 RAG
  2. 补上引用和拒答逻辑
  3. 从纯向量升级到混合检索
  4. 加日志和评测面板
  5. 最后再考虑复杂优化,比如查询改写、多路召回、答案反馈学习

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

  • 如果知识量小、更新慢、问题固定,轻量 RAG 就够了
  • 如果文档复杂、权限严格、回答要可审计,那就必须按生产架构来做
  • 如果检索命中率还不稳定,先别急着调 Prompt,更别急着换超大模型

因为在企业知识库问答里,真正把系统拉开差距的,往往不是模型本身,而是你怎么把“知识、检索、生成、约束”组织成一个可信的闭环。


分享到:

上一篇
《区块链智能合约安全审计实战:从常见漏洞识别到自动化检测流程搭建-242》
下一篇
《大模型推理性能优化实战:从量化、KV Cache 到并发调度的系统化落地指南》