上个季度,公司内部的制度文档、技术手册和项目总结散落在网盘、wiki、个人电脑上,员工每次找资料都得问一圈同事,最后答案还不一定准。我们尝试过让员工直接问大模型,结果模型一本正经地给出不存在的制度条款。这就是企业内部知识管理最典型的痛点——资料多但不好检索,大模型聪明但不知道“你们公司的事”。后来我们花了两周时间,基于RAG(检索增强生成)搭了一套私有知识库问答系统,把几百份内部文档全部喂进去,员工现在直接问“年假怎么休”“报销上限是多少”“这个接口怎么调”,系统能给出带出处、不带编造的答案。这套东西从架构到代码,基本可以复用到大多数企业内部场景,这篇就把完整落地过程写出来。
RAG的核心思路并不复杂:先让大模型“开卷考试”,而不是“闭卷硬答”。你预先从私域文档里检索出可能相关的内容片段,拼进提示词里,再让模型基于这些片段生成回答。这样一来,答案有据可循,知识可以随时更新,数据不需要传给外部服务,完美匹配企业私有知识库的需求。下面我会从设计方案、工具选型、代码实现到踩坑优化,把一个真正能上线的RAG系统完整拆开。
1. 为什么企业私有知识库需要RAG
1.1 传统知识管理的老问题
企业里最常见的知识库形态,无非是wiki、共享网盘、OA系统里的文档中心。这些东西普遍有三个问题。
第一是检索靠“关键词匹配”,搜出来的结果又杂又乱。你搜“报销流程”,可能出来一堆同事闲聊里提到“报销”的聊天记录,真正有用的《差旅报销管理制度》反而不排在前面。第二是知识分散不统一,同一个问题在不同文档里可能有两种说法,甚至互相矛盾,员工不知道以哪个为准。第三是知识更新滞后,制度文档放在网盘里没人维护,发布时间还是三年前。
我们内部做过一次调研,超过60%的员工觉得找资料比干活还累,超过40%的人承认遇到问题第一反应是问同事而不是查文档。这说明知识库如果不好用,员工就会用脚投票,最后文档沦为摆设。
1.2 RAG解决了哪些痛点
RAG系统本质上是给大语言模型装了一个“企业内部百科”的搜索接口,把上面三个问题一次性解决。
- 检索质量高:向量检索能把语义相近的内容找出来,你在系统里问“出差住宿标准是多少”,它知道你在找差旅制度里关于住宿费的那段话,而不是只做字面匹配。
- 回答有依据:生成答案时,模型被限定只能参考检索回来的片段,不再凭空编造。我们要求模型在找不到答案时直接说“资料中未找到”,不强行回答。
- 知识可持续更新:企业内部制度一变,只要把新文档传进知识库,下次回答就自动用新内容,不需要重新训练模型。
- 数据私有可控:整套系统从向量数据库到嵌入模型到生成模型,全部可以部署在内网,核心业务数据完全不出域。
1.3 合适与不适用的边界
RAG不是万能的。我们踩过几个坑之后,对这个边界非常明确。
适合的场景:制度问答类(人事制度、财务制度、行政流程)、产品文档和技术文档查询、内部知识经验沉淀,以及客服话术辅助。这些场景的特点是高信息密度、答案相对稳定、不需要复杂推理。
不适合的场景:需要多跳推理的复杂问题(比如“A项目和B项目加起来的总预算内,还能不能加入C项目的三期费用”),这类问题一个检索片段搞不定;实时性要求极高的场景(金融交易决策),检索加生成的链路耗时可能让业务等不及;以及本身就没什么文档积累的领域,RAG无米下锅。
2. RAG系统的完整链路拆解
2.1 从文档到答案的四个阶段
一套标准的RAG流程,拆开了就四步:文档加载、文本切片、向量化入库、检索生成。
文档加载是把PDF、Word、Markdown、TXT等各类企业内部格式统一读取成纯文本。文本切片是把长文档切分成固定长度的小段,因为大模型和向量模型对输入长度都有上限,而且整篇文档塞进去检索精度会很差。向量化入库是调用嵌入模型把每个切片转成向量数组,存进向量数据库。检索生成是当用户提问时,把问题也转成向量,从库里找出最相似的几个切片,连同问题一起交给大模型生成答案。
这四步每一步都有讲究,但影响最大的是切片和检索,后面单独展开。
2.2 切片粒度:效果的第一道关口
RAG效果好不好,切片粒度起了决定性作用。切太大,一段里包含太多无关信息,向量表征被稀释,检索召回精度下降;切太小,语义不完整,召回的片段可能只有半句话,喂给大模型也读不出完整含义。
最常用的策略是固定窗口加重叠切片。例如把文档按300到500个字符切一段,每段之间重叠50到100字符。重叠的目的是避免某个完整句子恰好被拦腰切断导致语义丢失。
这个参数不是越细越好,要看具体文档类型。规章制度类文档,一个条款往往一两百字,切500字一段就太粗了,一个段落里混入两三个条款,检索时容易互相干扰。而技术手册、研发文档这类上下文依赖强的文本,切太碎会导致“这个接口”不知道指哪个接口。我们最终是制度类文档用200字符、重叠30,技术手册用400字符、重叠80,效果比统一用一个参数好很多。
2.3 检索与重排:决定答案质量的核心机制
检索是RAG的发动机。只用向量检索会漏掉精确关键词命中的内容,只用关键词检索又接不住语义变体。真实场景里,“人工成本”和“人力成本”是一个意思,关键词就搜不出来。
所以成熟的方案是混合检索加重排。混合检索把向量召回和关键词召回都跑一遍,合并结果;重排则把召回的十几个候选片段,用专门的排序模型挑出最相关的三到五个给大模型。
重排这个动作很多人会忽略。早期我们跳过重排环节,直接top5喂给模型,结果模型经常被一两个低相关片段带偏。加上bge-reranker重排之后,同样的top5,准确率体感提升非常明显,后面代码部分会给出具体用法。
3. 工具选型:不追新,只求稳
3.1 向量数据库怎么选
向量数据库是RAG的地基。面向企业私有部署,核心考量是:数据量多大、要不要持久化、运维成本能不能接受。
| 方案 | 适合规模 | 部署难度 | 持久化 | 适合场景 |
|---|---|---|---|---|
| Chroma | 百万级向量以下 | 极低,pip安装即用 | 支持磁盘持久化 | 中小团队快速验证、百份文档内 |
| FAISS | 千万级以下 | 较低,配合自建存储 | 需自行管理索引文件 | 离线批量检索、轻量集成 |
| Milvus | 亿级向量 | 高,涉及集群组件 | 完整分布式能力 | 大规模知识库、需要水平扩展 |
| pgvector | 取决于PostgreSQL | 中,复用已有PG | 依托PG | 已有PG业务,统一存储 |
我们最终选的是Chroma。原因很直接:初始文档量只有几百份,切片后不到两万个向量,Chroma完全能扛住,而且一个pip包装完就能跑,凭一句pip install chromadb搞定,不需要额外运维一个分布式系统。如果后续数据量到百万级,再平滑迁移Milvus也不迟。
一个小提醒:不要把向量数据库的选型看得太重。真正决定效果的是切片策略、嵌入模型和检索方案,数据库只要能存能查就够了。一开始就用Milvus的团队,往往还没等到数据量上来,就先被集群维护折腾疯了。
3.2 嵌入模型怎么选
嵌入模型把文本变成向量,是语义检索的关键。企业场景下我的建议很明确:优先选开源中文模型。
之前我们有同事图省事直接调OpenAI的Embedding接口,效果确实不错,但有两个隐患:一是文档内容要发给外部API,制度文件和数据合规那道关就过不去;二是每次向量化都得保持网络畅通,内网环境玩不转。
目前开源中文嵌入模型里,BGE系列(BAAI/bge-small-zh-v1.5和bge-large-zh-v1.5)是综合最优解。small模型显存占用小,普通CPU都能跑,单条文本向量化只要几十毫秒;large模型精度高一点,但资源开销翻倍。我们几百份文档量级,用bge-small-zh-v1.5完全足够,向量化速度飞快,成本几乎为零。
注意嵌入模型的输入长度限制,bge系列最长支持512个token,所以切片长度要控制在合理范围。另外BGE官方推荐在检索时给query和passage加不同的前缀,bge-small-zh-v1.5用HuggingFaceEmbeddings默认封装时不加前缀也能用,但加上前缀能提升一点精度,下面代码会说。
3.3 生成模型怎么选
生成模型是最后决定“人话讲得好不好”的环节。两条路线:调API或私有化部署。
调API是最快的方式。技术方案评估阶段,我们直接对接一个兼容OpenAI格式的模型API,一天就把整个链路跑通了。优点是快,缺点是每问一个问题都产生一次API调用费用,且数据必须送出去。
私有化部署适合对数据有要求的企业。我们在内网用Ollama跑Qwen和ChatGLM系列模型,7B或14B版本,16G显存的机器就够了。部署好之后,本地起一个兼容OpenAI的接口,业务代码完全不用改。
参数上最需要注意的是temperature。问答场景建议设成0到0.3,温度设太高,模型就容易在有限的检索资料基础上发挥想象力,编造内容的风险大幅上升。
3.4 框架到底要不要用
LangChain和LlamaIndex这类的RAG框架能帮你把加载、切片、向量化、检索、生成整个链路串起来,开发效率很高。但框架抽象层级高,出问题时定位麻烦。
我的建议是核心链路用LangChain的组件快速搭建,展示型Demo直接用它;但真正决定上线效果的定制逻辑,比如特殊格式文档解析、重排序策略、提示词模板,还是要自己写代码控制。
4. 从0到1的完整代码实现
4.1 环境准备与依赖安装
建议用Python 3.10以上版本,新建虚拟环境后安装以下核心依赖:
pip install langchain langchain-community chromadb sentence-transformers如果用到OpenAI兼容接口,还需要:
pip install langchain-openai如果要在本地跑重排模型,需要:
pip install FlagEmbedding测试环境说明:我这边是Ubuntu 22.04,Python 3.10,机器有一张16G显存的显卡,但嵌入模型跑在CPU上也完全没问题。RAG链路本身不重,重的是最后那个生成大模型。
4.2 文档清洗与加载
这一步的目标是把零散文档统一读成带元数据的文本对象。我们内部以Markdown和TXT为主,所以用DirectoryLoader就能扫整个目录。如果需要读PDF,换成PyPDFLoader。
from langchain_community.document_loaders import DirectoryLoader, TextLoader # 加载 docs 目录下所有 txt 文件 loader = DirectoryLoader( "./docs", glob="**/*.txt", loader_cls=TextLoader, loader_kwargs={"encoding": "utf-8"}, ) documents = loader.load()这里必须提醒一个坑:Windows下TXT文件可能是GBK编码,不指定编码会报错或乱码。我们统一要求团队导出的文档都转成UTF-8。如果有历史文件乱码,先用脚本批量清洗一遍再入库。
清洗比加载更重要。原始文档里经常有页眉页脚、重复标题、表格错位、全角半角混用,这些噪声直接进切片,影响向量质量。我们的清洗脚本里做了几件事:去除空行和多余空格、统一换行符、去掉明显的页眉页脚标记、把全角字符转半角(中文标点除外)。
4.3 切片与向量化入库
切片用LangChain的RecursiveCharacterTextSplitter,按照前面说的,用两套参数分别处理制度类和技术类文档。
from langchain.text_splitter import RecursiveCharacterTextSplitter # 制度类文档:小窗口,避免一段混入多个条款 policy_splitter = RecursiveCharacterTextSplitter( chunk_size=200, chunk_overlap=30, separators=["\n\n", "\n", "。", "!", "?", ".", "!", "?", ";", ";", ",", ",", ""], ) # 技术类文档:大窗口,保留上下文 tech_splitter = RecursiveCharacterTextSplitter( chunk_size=400, chunk_overlap=80, separators=["\n\n", "\n", "。", "!", "?", ".", "!", "?", ";", ";", ",", ",", ""], )切片后是嵌入模型。我们使用BGE中文模型,通过HuggingFaceEmbeddings封装。注意下面代码里特意给query和passage加了BGE官方推荐的前缀。
from langchain_community.embeddings import HuggingFaceEmbeddings embeddings = HuggingFaceEmbeddings( model_name="BAAI/bge-small-zh-v1.5", encode_kwargs={"normalize_embeddings": True}, model_kwargs={"device": "cpu"}, )BGE官方的使用规范是query加为这个句子生成表示以用于检索相关文章:,passage加为这个句子生成表示:前缀。HuggingFaceEmbeddings默认不处理前缀,所以要么手动在切片的page_content里统一加上前面说的passage前缀,要么就用下面的方式逐批向量化:
from sentence_transformers import SentenceTransformer model = SentenceTransformer("BAAI/bge-small-zh-v1.5", device="cpu") docs_texts = [f"为这个句子生成表示:{doc.page_content}" for doc in chunks] embeddings_list = model.encode(docs_texts, normalize_embeddings=True)入库到Chroma,带持久化目录:
from langchain_community.vectorstores import Chroma # 方式一:直接用组件封装,传入文本和向量化函数 vectordb = Chroma.from_documents( documents=chunks, embedding=embeddings, persist_directory="./chroma_db", ) vectordb.persist()上面vectordb.persist()是老版本API,新版Chroma目录写入会自动执行,用新版本的话这一步可以省略。
4.4 检索与问答链路
先建retriever,参数控制每次召回候选数量:
retriever = vectordb.as_retriever( search_kwargs={"k": 5}, )这里k=5表示每次检索拿回5个切片。k值太小容易漏答案,k值太大容易混入噪声,5是一个比较合理的起点,后面调试时可以改成8或者10配合重排使用。
然后是生成模型。我们最终部署的是内网Ollama上的Qwen模型,通过OpenAI兼容接口接入:
from langchain_openai import ChatOpenAI llm = ChatOpenAI( base_url="http://your-ollama-host:11434/v1", api_key="EMPTY", model="qwen2.5:14b", temperature=0.2, )如果你用的是其他API服务,只要服务兼容OpenAI接口格式,这个地方只改base_url和model就行。
提示词模板是决定回答规范的关键。我们做得比较严格,明确要求模型不能编造:
from langchain.prompts import PromptTemplate from langchain.chains import RetrievalQA prompt_template = """你是一个企业知识库问答助手。请基于以下资料回答问题。 要求: 1. 只能使用资料中提供的信息作答,不得编造资料中不存在的内容。 2. 如果资料中没有相关信息,请直接回复:根据已有资料无法回答该问题。 3. 回答时先给出结论,再引用对应资料内容作为依据,引用时注明资料名称。 4. 回答简洁明确,不要废话。 资料: {context} 问题:{question} 回答:""" prompt = PromptTemplate( template=prompt_template, input_variables=["context", "question"], ) qa_chain = RetrievalQA.from_chain_type( llm=llm, retriever=retriever, chain_type="stuff", return_source_documents=True, chain_type_kwargs={"prompt": prompt}, )调用:
result = qa_chain.invoke({"query": "员工年假天数是怎么规定的?"}) print(result["result"]) for doc in result["source_documents"]: print(doc.metadata.get("source"), doc.page_content[:100])chain_type="stuff"表示把检索到的切片全部塞进一次提示词里,适合切片少、内容可控的场景,也是我们实际使用的配置。
4.5 加一个重排环节
如果在检索召回之后加上重排,推荐用BGE的重排模型bge-reranker-base。它的用法很轻量:
from FlagEmbedding import FlagReranker reranker = FlagReranker("BAAI/bge-reranker-base", use_fp16=True) question = "员工年假天数是怎么规定的?" retrieved_docs = retriever.invoke(question) # 召回 10 个候选 pairs = [[question, doc.page_content] for doc in retrieved_docs] scores = reranker.compute_score(pairs) # 按重排得分重新排序,取前 3 个 doc_score_pairs = list(zip(retrieved_docs, scores)) doc_score_pairs.sort(key=lambda x: x[1], reverse=True) final_docs = [doc for doc, _ in doc_score_pairs[:3]]然后把final_docs的文本拼进提示词,替代原来的context。重排模型和嵌入模型是两回事,它不生产向量,只负责给“问题和文本”的相关性打分,所以可以和嵌入模型分开选择。
5. 效果调优:让回答从“能用”到“好用”
5.1 先评估,再调参
很多团队的RAG落地死在“效果说不清”。上线之前,我们整理了一套覆盖核心业务的30个问题清单,包含制度问答、技术文档查找、边缘Case三类,人工标注了标准答案。每改一次参数,跑一遍清单,人工打分。
这个动作非常建议做。没有评估集,你调切片参数就靠拍脑袋,今天改出感觉好一点,明天又回去了。有了一套固定的评估集,每次改动都有可对比的基准线,后面所有优化都是在一个明确方向上叠加。
5.2 调切片不如调检索
我们实测下来,在已经选好嵌入模型的情况下,提升效果最明显的动作是混合检索和重排,而不是反复调chunk_size。
向量检索擅长语义匹配但可能漏精确关键词,关键词检索(BM25)负责精确匹配。把两者结果合并,再重排,效果比单纯加大多检索k值好得多。回归测试里,加重排后准确率从72%提到了84%,提升非常明显。
# 关键词检索部分,用 rank_bm25 实现 from rank_bm25 import BM25Okapi # 以全量切片构建BM25索引 tokenized_chunks = [simple_tokenizer(chunk.page_content) for chunk in chunks] bm25 = BM25Okapi(tokenized_chunks) tokenized_query = simple_tokenizer(question) bm25_scores = bm25.get_scores(tokenized_query) top_bm25_indices = bm25_scores.argsort()[-5:][::-1]然后把BM25召回的结果和向量召回的结果做并集去重,再交给重排模型选前3个。这里simple_tokenizer需要自定义,最简单的就是按字符拆词,中文场景下用jieba分词更佳。
5.3 提示词里隐藏的坑
提示词对回答风格的约束,比很多人想象的大。我们迭代了好几版提示词,有三个经验很值得分享。
一是明确否定指令。只写“不要编造”远远不够,必须告诉模型“资料中没有就直说”。这个直接的指令比委婉的说法有效得多。
二是要求“先结论后依据”。没有这个约束时,模型经常先展开一堆背景解释才落到重点,员工看回答要多花几秒。加了这个要求之后,回答变得非常直接,体验提升明显。
三是控制回答长度。说明类问题模型容易长篇大论,提示词里加一句“回答简洁明确,不要废话”,输出长度会显著收敛。
5.4 多轮对话的取舍
RAG系统通常还需要支持多轮追问。LangChain有ConversationalRetrievalChain,但我们在实际使用中发现,多轮对话会显著增加RAG链路复杂度。用户问“那这个能报销吗”,系统得先知道“这个”指代什么,这个指代消解本身就要跑一次模型,成本高且容易错。
我们最终采用了折中方案:前端不做多轮记忆,用户每次提问都是独立请求,但要求用户问题尽量写完整。在问答场景里,这个取舍换来的是答复更稳定,代价是用户需要多打几个字,实际反馈完全能接受。
6. 踩坑实录与排查技巧
6.1 检索一直返回不相关内容
这是我们遇到的第一个问题,现象是问“年假制度”,系统返回的却是“绩效奖金”相关内容。
排查顺序是这样的:先看切片是否能覆盖问题关键词,发现没问题;再看向量化,用余弦相似度人工算了几个相关句子,发现比分确实不高;最终定位到切片太长,一个切片里塞了三四个制度条款,每个条款语义互相稀释,向量表征往“中间值”靠,导致什么都不像。改成小窗口切片之后检索精确度明显回升。
6.2 大模型频繁编造答案
编造答案的根源通常有两个:一是检索到的切片本身不相关,模型找不到对应内容,只能硬编;二是温度参数太高。
我们先把temperature降到了0.1,编造概率大幅下降。同时在提示词里加严约束“资料中未找到就直接说未找到”。另外检查了retriever的k值,原本设置8个切片返回,里面混了两三条不相关的,把k降到5之后,模型读到的干扰信息更少,回答质量更稳。
6.3 PDF表格和扫描件无法加载
企业内部文档最常见的坑是PDF表格、扫描件和图片型PDF。文字型PDF用PyPDFLoader能读,但表格结构会丢失,一行多列变成长字符串。扫描件则根本没有文本层,必须OCR。
我们的处理方案:制度类PDF统一要求源头提供Word或Markdown版本,解决不了的话用OCR链路兜底。OCR用PaddleOCR跑一遍,把识别出的文字存成TXT再入库。这里要特别注意表格扫描件的OCR效果,PaddleOCR对简单表格结构基本能用,但复杂合并单元格还是得人工复核。
6.4 数据量变大后查询越来越慢
Chroma对百万级向量以内的查询速度都非常快,但如果你的切片段数量上去之后开始明显变慢,优先检查是不是每次启动都在重复向量化入库。正确做法是:首次入库后,后续功能代码从persist_directory加载已有库,而不是每次重新from_documents。
# 二次启动时,直接从持久化目录加载 vectordb = Chroma( persist_directory="./chroma_db", embedding_function=embeddings, )加载已有的向量库不需要重新向量化几千份文档,秒级启动。这个优化我们把系统启动时间从几分钟降到了几秒。
6.5 新文档入库,索引不更新
日常运营中保证离线分阶段追加文档,比如每天定时把新增文档跑一遍向量化并追加到库中。
new_docs = load_new_documents() # 只加载增量文档 new_chunks = split_documents(new_docs) vectordb.add_documents(new_chunks)注意不要反复调用persist()或者对同一个持久化目录重复初始化,不然容易出现旧数据残留和重复数据叠加的问题。
最后说点实在话
这套RAG系统搭起来之后,部门同事用了大概一周,反馈两极分化。前端界面做得好看不重要,真正让大家愿意用的是两件事:回答有出处,点击引用能跳回原文档;查不到的时候系统会老实说查不到,而不是一本正经骗人。我追问过几个不常用的同事,他们最后说一句“反正比问行政的回复快”,我就知道这事成了。
如果看完这篇你也要动手做,我给三个建议。第一,别纠结模型参数和框架版本,先把检索链路跑通,让用户用起来再迭代。第二,务必准备一套评估QA集,效果调优全靠它指方向。第三,从最小闭环开始,先放人事制度和财务制度两类文档,把语料质量和回复体验打磨稳了,再往外扩知识域。做企业内部工具,稳定可靠永远比功能炫酷更值钱。