
上个季度公司内部的制度文档、技术手册和项目总结散落在网盘、wiki、个人电脑上员工每次找资料都得问一圈同事最后答案还不一定准。我们尝试过让员工直接问大模型结果模型一本正经地给出不存在的制度条款。这就是企业内部知识管理最典型的痛点——资料多但不好检索大模型聪明但不知道“你们公司的事”。后来我们花了两周时间基于RAG检索增强生成搭了一套私有知识库问答系统把几百份内部文档全部喂进去员工现在直接问“年假怎么休”“报销上限是多少”“这个接口怎么调”系统能给出带出处、不带编造的答案。这套东西从架构到代码基本可以复用到大多数企业内部场景这篇就把完整落地过程写出来。RAG的核心思路并不复杂先让大模型“开卷考试”而不是“闭卷硬答”。你预先从私域文档里检索出可能相关的内容片段拼进提示词里再让模型基于这些片段生成回答。这样一来答案有据可循知识可以随时更新数据不需要传给外部服务完美匹配企业私有知识库的需求。下面我会从设计方案、工具选型、代码实现到踩坑优化把一个真正能上线的RAG系统完整拆开。1. 为什么企业私有知识库需要RAG1.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.04Python 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_clsTextLoader, 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_size200, chunk_overlap30, separators[\n\n, \n, 。, , , ., !, ?, , ;, , ,, ], ) # 技术类文档大窗口保留上下文 tech_splitter RecursiveCharacterTextSplitter( chunk_size400, chunk_overlap80, separators[\n\n, \n, 。, , , ., !, ?, , ;, , ,, ], )切片后是嵌入模型。我们使用BGE中文模型通过HuggingFaceEmbeddings封装。注意下面代码里特意给query和passage加了BGE官方推荐的前缀。from langchain_community.embeddings import HuggingFaceEmbeddings embeddings HuggingFaceEmbeddings( model_nameBAAI/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, devicecpu) docs_texts [f为这个句子生成表示{doc.page_content} for doc in chunks] embeddings_list model.encode(docs_texts, normalize_embeddingsTrue)入库到Chroma带持久化目录from langchain_community.vectorstores import Chroma # 方式一直接用组件封装传入文本和向量化函数 vectordb Chroma.from_documents( documentschunks, embeddingembeddings, persist_directory./chroma_db, ) vectordb.persist()上面vectordb.persist()是老版本API新版Chroma目录写入会自动执行用新版本的话这一步可以省略。4.4 检索与问答链路先建retriever参数控制每次召回候选数量retriever vectordb.as_retriever( search_kwargs{k: 5}, )这里k5表示每次检索拿回5个切片。k值太小容易漏答案k值太大容易混入噪声5是一个比较合理的起点后面调试时可以改成8或者10配合重排使用。然后是生成模型。我们最终部署的是内网Ollama上的Qwen模型通过OpenAI兼容接口接入from langchain_openai import ChatOpenAI llm ChatOpenAI( base_urlhttp://your-ollama-host:11434/v1, api_keyEMPTY, modelqwen2.5:14b, temperature0.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( templateprompt_template, input_variables[context, question], ) qa_chain RetrievalQA.from_chain_type( llmllm, retrieverretriever, chain_typestuff, return_source_documentsTrue, 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_typestuff表示把检索到的切片全部塞进一次提示词里适合切片少、内容可控的场景也是我们实际使用的配置。4.5 加一个重排环节如果在检索召回之后加上重排推荐用BGE的重排模型bge-reranker-base。它的用法很轻量from FlagEmbedding import FlagReranker reranker FlagReranker(BAAI/bge-reranker-base, use_fp16True) 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(keylambda x: x[1], reverseTrue) 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_functionembeddings, )加载已有的向量库不需要重新向量化几千份文档秒级启动。这个优化我们把系统启动时间从几分钟降到了几秒。6.5 新文档入库索引不更新日常运营中保证离线分阶段追加文档比如每天定时把新增文档跑一遍向量化并追加到库中。new_docs load_new_documents() # 只加载增量文档 new_chunks split_documents(new_docs) vectordb.add_documents(new_chunks)注意不要反复调用persist()或者对同一个持久化目录重复初始化不然容易出现旧数据残留和重复数据叠加的问题。最后说点实在话这套RAG系统搭起来之后部门同事用了大概一周反馈两极分化。前端界面做得好看不重要真正让大家愿意用的是两件事回答有出处点击引用能跳回原文档查不到的时候系统会老实说查不到而不是一本正经骗人。我追问过几个不常用的同事他们最后说一句“反正比问行政的回复快”我就知道这事成了。如果看完这篇你也要动手做我给三个建议。第一别纠结模型参数和框架版本先把检索链路跑通让用户用起来再迭代。第二务必准备一套评估QA集效果调优全靠它指方向。第三从最小闭环开始先放人事制度和财务制度两类文档把语料质量和回复体验打磨稳了再往外扩知识域。做企业内部工具稳定可靠永远比功能炫酷更值钱。