企业内部知识库的 RAG 落地记录
以员工制度问答为例,拆开文件入库、权限过滤、检索生成、引用溯源、离线评测和生产部署中的具体问题。
前一版文章把框架、模型、数据库和部署都讲了一遍,读完却很难回答一个实际问题。公司要做员工制度问答,第一版到底该做什么,哪些设计会在上线前返工。
这次把范围收紧,只看一个场景。员工在内部系统询问考勤、报销、休假和奖金制度,系统从正式文件中找依据,给出带出处的回答。没有足够材料时明确停下来。不同部门的文件不能串库,旧制度也不能混进新答案。
本文整理自一个 RAG MVP 的设计记录。已经跑通的部分包括文件上传、轻量解析、向量索引、检索生成和来源返回。权限表还没有进入查询路径,索引任务仍在 API 进程内运行,多知识库结果也没有 rerank。后文给出的改造方案用于说明下一版怎么做,不代表这些能力已经上线。
如果需要先补术语和选型背景,可以看 RAG 技术全景与选型。这篇只处理落地过程。
先写清楚交付结果
Section titled “先写清楚交付结果”内部知识库很容易被做成一个能聊天的搜索框。演示时看着完整,实际验收会卡在几个很朴素的问题上。
- 回答能否定位到文件版本、章节和页码
- 没有依据时能否拒答
- 两份制度冲突时能否把冲突交给用户判断
- 用户能否搜到自己无权查看的片段
- 新版文件入库失败时,旧版是否还能正常提供服务
- 模型、切分方式或检索参数变化后,效果是否真的提高
第一版的交付标准可以收敛成四条。
有据可查
每个关键结论都要绑定来源。来源至少包含文件 ID、不可变版本号、章节路径、页码和片段 ID。只返回文件名不够,同名文件和多版本文件会让引用失去意义。
无据停答
系统找不到足够材料时返回 insufficient_evidence。模型可以说明缺少什么材料,不能靠常识补齐内部制度。
权限先于检索
用户身份由服务端认证结果提供。查询范围由权限服务计算。客户端传来的知识库列表只能缩小范围,不能扩大范围。
版本切换可恢复
新版本完成解析和索引后再切为生效状态。失败任务允许重试,重复执行不会产生重复片段,服务重启也不会丢任务。
这四条会直接决定后面的数据模型和服务拆分。它们比先选 LlamaIndex 还是 LangChain 更早。
MVP 已经走到哪里
Section titled “MVP 已经走到哪里”现有 MVP 的处理路径很短。
上传文件 -> 保存对象存储 -> 写入文件记录 -> 解析 PDF、TXT、Markdown 或 CSV -> 生成向量并写入 pgvector -> 按相似度取回片段 -> 交给 LLM 生成回答 -> 返回答案和片段文本它适合确认模型接口、数据库和基本问答能否接通,也暴露了五个需要优先处理的问题。
| 现状 | 直接后果 |
|---|---|
查询接口接收客户端传入的 user_id | 身份可以伪造,权限表即使存在也没有约束力 |
asyncio.create_task 执行索引 | API 重启后任务丢失,多副本部署时状态难以追踪 |
| 每个知识库动态创建一张向量表 | 迁移、监控和模型升级都会随表数量变复杂 |
| 多知识库结果直接比较向量分数 | 不同索引和不同模型的分数未必处在同一尺度 |
| 来源只有文件名和片段文本 | 文件更新后无法证明答案来自哪个版本 |
所以这一版仍是技术原型。它证明基本问答可以接通,没有证明系统可以安全地交给员工使用。
服务边界怎么拆
Section titled “服务边界怎么拆”规模不大时,PostgreSQL 加 pgvector 足够同时承载业务记录和向量。对象存储保留原文件,API 处理认证和查询,独立 worker 负责解析与索引。
浏览器或业务系统 | vAPI 服务 |-- 认证、权限、查询、引用组装 |-- PostgreSQL 业务表 |-- pgvector 片段表 | +--> 索引任务表或消息队列 --> Worker |-- 文档解析 |-- Chunking |-- Embedding +-- 版本切换
对象存储 |-- 原文件 +-- 解析产物和可选的页面快照API 和 worker 可以先放在同一个代码仓库,部署时分成两个进程。这样不用一开始就拆微服务,也能避免耗时解析堵住请求进程。
模型供应商再包一层窄接口。
class EmbeddingProvider(Protocol): async def embed_documents(self, texts: list[str]) -> list[list[float]]: ... async def embed_query(self, text: str) -> list[float]: ...
class Generator(Protocol): async def answer(self, question: str, contexts: list[str]) -> str: ...embed_documents 和 embed_query 分开保留了检索模型的非对称用法。百炼的官方文档也建议在检索任务中区分 query 和 document。换供应商时,业务层不需要知道 HTTP 字段和批次限制。
数据模型先解决版本和幂等
Section titled “数据模型先解决版本和幂等”原来的 KnowledgeBase、File、Permission 三类记录不足以支撑更新、重试和审计。更稳妥的最小模型包含这些表。
| 表 | 主要职责 |
|---|---|
knowledge_bases | 知识库配置、所属租户、当前 Embedding 配置 |
documents | 逻辑文件,保存标题、业务分类和稳定 ID |
document_versions | 每次上传产生一个不可变版本 |
chunks | 片段正文、向量、位置和生效状态 |
index_jobs | 索引任务、租约、重试次数和失败原因 |
kb_permissions | 用户或用户组对知识库的访问关系 |
query_logs | 查询、命中片段、模型版本、延迟和结果状态 |
关键字段可以收敛成下面这些。
CREATE TABLE chunks ( id uuid PRIMARY KEY, tenant_id uuid NOT NULL, kb_id uuid NOT NULL, document_id uuid NOT NULL, document_version_id uuid NOT NULL, chunk_no integer NOT NULL, section_path text[], page_from integer, page_to integer, content text NOT NULL, content_hash text NOT NULL, embedding_model text NOT NULL, embedding_version text NOT NULL, embedding vector(1024) NOT NULL, is_active boolean NOT NULL DEFAULT false, created_at timestamptz NOT NULL DEFAULT now(), UNIQUE (document_version_id, chunk_no, embedding_version));唯一约束让 worker 重跑时可以 UPSERT。document_version_id 和 is_active 让新旧版本短暂共存。section_path 与页码负责引用定位。embedding_model 和 embedding_version 用来判断旧向量能否继续使用。
不再默认每库一张表
Section titled “不再默认每库一张表”每个知识库一张向量表看起来隔离得很干净,知识库一多就会增加动态 DDL、迁移、索引监控和模型升级的成本。更常见的起点是让同一套 Embedding 配置共用一张 chunks 表,用 tenant_id、kb_id 和 is_active 过滤。这里的同一套配置包括模型、版本、维度和距离度量,只对上维度还不够。
这条规则也有边界。数据量很小或权限过滤非常挑剔时,精确检索加普通索引可能更合适。租户规模差异很大时,可以按租户或知识库分区。强隔离场景还可以使用独立表或独立数据库。pgvector 官方文档也把分区和独立表列为多租户隔离手段。
需要提前固定的是 Embedding 维度。向量列一旦定义为 vector(1024),写入其他维度会失败。模型升级先新建兼容的索引版本,完成回填和评测后再切流量,不要直接覆盖旧向量。
文件入库要能重启和重试
Section titled “文件入库要能重启和重试”上传接口只负责接收文件、校验格式、保存原件并登记任务。文件解析往往需要几十秒,返回 202 Accepted 更符合真实状态。
@router.post("/knowledge-bases/{kb_id}/documents", status_code=202)async def upload_document( kb_id: UUID, file: UploadFile, principal: Principal = Depends(current_principal), session: AsyncSession = Depends(db_session),): await require_kb_write_access(session, principal, kb_id)
document_version = await save_new_version( session=session, tenant_id=principal.tenant_id, kb_id=kb_id, file=file, ) job = await enqueue_index_job(session, document_version.id) await session.commit()
return { "document_version_id": str(document_version.id), "job_id": str(job.id), "status": "queued", }这里没有接收 user_id。调用者身份来自认证中间件解析后的 principal。服务端先检查写权限,再保存文件。
索引状态可以保持简单。
uploaded -> queued -> parsing -> embedding -> indexed | +-> failed -> queuedindex_jobs 至少记录 status、attempts、available_at、leased_until、worker_id 和 last_error。小团队可以用数据库任务表配合 FOR UPDATE SKIP LOCKED 领取任务。已有 Redis 或 RabbitMQ 时再接 Celery、RQ 或其他队列。选哪种队列没有数据模型重要,任务状态和幂等不能只放在内存里。
FastAPI 的 BackgroundTasks 适合响应后执行小任务。官方文档对重计算任务也建议考虑能跨进程、跨服务器工作的任务队列。文件解析和批量 Embedding 正好属于这一类。
新版本完成后再切换
Section titled “新版本完成后再切换”文件更新时不要先删旧片段。可靠顺序如下。
- 上传原文件并计算校验值
- 创建
document_versions记录,新版本暂不生效 - 解析、切分并写入新片段
- 校验片段数量、向量维度和必填元数据
- 在一个事务里停用旧版本,启用新版本
- 过一段保留期后清理旧向量和解析产物
新任务失败时,旧版本仍能回答。重复上传同一内容时,可以通过文件哈希和解析配置哈希判断是否需要重建索引。
解析和切分要跟着文档结构走
Section titled “解析和切分要跟着文档结构走”制度文件很少是连续纯文本。标题层级、条款编号、表格、附件和脚注都会影响检索。
入库后先保存一个与模型无关的标准化文档结构。
{ "document_version_id": "docv_2026_003", "blocks": [ { "type": "paragraph", "section_path": ["差旅制度", "住宿标准"], "page": 8, "text": "员工前往一线城市出差时……" } ]}这一层保住标题、页码和阅读顺序。后面更换 Embedding 或切分参数时,可以直接从标准化结果重建,不必再次解析原 PDF。
不同内容用不同切分方式
Section titled “不同内容用不同切分方式”| 内容类型 | 初始切分方式 | 容易出现的问题 |
|---|---|---|
| 制度条款 | 按标题和条款边界切分,超长条款再按 token 切 | 条件和例外被拆开 |
| FAQ | 问题与答案保持在同一片段 | 只召回答案,缺少问题语义 |
| 表格 | 保留表名、表头和行标题,按行组展开 | 数值离开列名后失去含义 |
| 长篇说明 | 子片段检索,父段落送入模型 | 上下文扩大后噪声增加 |
| 扫描 PDF | OCR 后保留页码和版面块 | 识别错误污染 Embedding |
固定写一个 chunk_size=512 不能解决这些差异。可以先比较 256、512、768 token 三组候选值,overlap 从 10% 左右起步,再根据召回失败样本调整。对制度文件,标题和条款边界通常比一个整齐的数字更有用。
每个片段还要带上用于展示和过滤的元数据。
metadata = { "tenant_id": tenant_id, "kb_id": kb_id, "document_id": document_id, "document_version_id": document_version_id, "section_path": ["差旅制度", "住宿标准"], "page_from": 8, "page_to": 8, "source_uri": source_uri, "content_hash": content_hash,}这些字段不该依赖 LLM 临时推断。解析阶段拿不到准确页码时,引用就应该显示章节和片段位置,不能生成一个看似精确的页数。
权限过滤必须进入检索语句
Section titled “权限过滤必须进入检索语句”权限校验只放在接口开头仍有漏洞。检索函数如果接收任意 kb_ids,后续重构很容易绕过入口校验。稳妥做法是让检索服务只接收服务端算出的允许范围。
allowed_kb_ids = await permission_service.readable_kbs( tenant_id=principal.tenant_id, user_id=principal.user_id, group_ids=principal.group_ids,)
if request.kb_ids: allowed_kb_ids &= set(request.kb_ids)
hits = await retriever.search( tenant_id=principal.tenant_id, allowed_kb_ids=allowed_kb_ids, question=request.question,)数据库查询再次带上租户、知识库和生效版本条件。
SELECT id, document_version_id, section_path, page_from, page_to, content, embedding <=> :query_embedding AS distanceFROM chunksWHERE tenant_id = :tenant_id AND kb_id = ANY(:allowed_kb_ids) AND is_active = trueORDER BY embedding <=> :query_embeddingLIMIT :candidate_k;PostgreSQL 的 Row-Level Security 可以再做一道防线。启用 RLS 后,如果没有匹配策略,普通访问会走默认拒绝。表所有者通常会绕过策略,FORCE ROW LEVEL SECURITY 可以让表所有者也受约束,超级用户和带 BYPASSRLS 的角色仍能绕过。线上应用连接不要使用这些高权限账号,测试时还要覆盖连接池复用和事务内租户上下文。
ALTER TABLE chunks ENABLE ROW LEVEL SECURITY;ALTER TABLE chunks FORCE ROW LEVEL SECURITY;
CREATE POLICY chunks_tenant_policy ON chunksUSING ( tenant_id = current_setting('app.tenant_id', true)::uuid);应用层权限过滤便于表达用户组和知识库授权,RLS 用来限制租户越界。两层都要有测试。测试用例至少包含无权限知识库、跨租户同名文件、空权限用户、权限刚撤销后的旧会话。
带过滤条件的 HNSW 要单独测
Section titled “带过滤条件的 HNSW 要单独测”pgvector 的近似索引会先扫描索引候选,再应用过滤条件。租户或知识库过滤很严格时,LIMIT 10 可能拿不到十条结果。官方文档给出的处理手段包括提高搜索范围、启用 iterative scan、为低基数条件建部分索引,以及按高基数条件分区。
因此不能只在全量数据上测向量召回。评测查询要带上真实权限范围。数据还不大时,精确检索常常更省心。等延迟数据证明需要 HNSW,再比较召回损失和性能收益。
检索从一个可解释基线开始
Section titled “检索从一个可解释基线开始”制度问答的第一条基线可以很简单。
用户问题 -> 生成 query embedding -> 在允许范围内做 Dense Retrieval -> 取候选片段 -> 去重和补相邻片段 -> 组装引用上下文 -> LLM 回答或拒答先把 Dense Retrieval 的失败样本留下。常见问题大致分成三类。
- 制度编号、产品型号和报销科目等精确词没有命中
- 含义相近的片段太多,正确依据排在后面
- 相关条款分散在多个章节,单片段信息不完整
第一类可以试 Hybrid Search,把全文检索和向量检索的排名用 RRF 合并。RRF 只使用名次,不直接比较 BM25 分数和向量距离。
RRF(d) = sum(1 / (k + rank_i(d)))第二类可以把 Dense Retrieval 的候选数放大,再用 Cross-Encoder 或供应商的 rerank 模型重排。第三类需要查询分解、父子片段或相邻片段扩展。三个改动解决的是不同故障,不必一次全装上。
Elastic 的 Hybrid Search 文档推荐 RRF 来合并全文和向量排名。这个建议能解释算法方向,是否适合当前知识库仍要用自己的评测集判断。
上下文组装不要简单拼 Top K
Section titled “上下文组装不要简单拼 Top K”召回结果进入 LLM 前,还要处理这些细节。
- 相同文档、相邻位置的片段去重或合并
- 保留标题路径,让模型知道条款所属章节
- 同一问题命中新旧制度时,只使用生效版本
- 多份生效文件冲突时保留双方,并标记
conflict - 按 token 预算截断,不能从条款中间硬切
上下文可以给每个来源分配稳定编号。
[S1]document_version_id = docv_2026_003section = 差旅制度 / 住宿标准page = 8content = ...生成提示只允许引用提供的来源编号。响应解析后再验证每个引用编号是否存在。引用存在仍不代表结论受它支持,后面的评测要单独检查这一点。
查询接口不要返回伪置信度
Section titled “查询接口不要返回伪置信度”向量相似度描述查询和片段在向量空间里的接近程度。它不等于答案正确概率,不适合直接显示成 91.23% 可信度。
请求体只保留业务需要的字段。
{ "question": "试用期员工能否申请年度奖金", "kb_ids": ["kb_hr_policy"]}响应显式区分回答状态。
{ "answer_status": "insufficient_evidence", "answer": "现有生效材料没有说明试用期员工是否适用年度奖金规则。", "sources": [ { "source_id": "S1", "document_id": "doc_bonus_policy", "document_version_id": "docv_2026_003", "title": "年度奖金管理办法", "section_path": ["适用范围"], "page_from": 2, "page_to": 2, "chunk_id": "chunk_018" } ], "trace_id": "trace_01J..."}answer_status 至少保留三种值。
| 状态 | 使用条件 |
|---|---|
answered | 来源足够且没有无法消解的冲突 |
insufficient_evidence | 召回内容无法支持结论 |
conflict | 多个有效来源给出不同规定 |
具体阈值要从标注集上确定。只用向量距离阈值会漏掉 rerank、上下文完整性和模型生成误差。
评测要拆开检索和回答
Section titled “评测要拆开检索和回答”这次不补写一组漂亮数字。现有材料里没有保存人工标注集和完整运行日志,凭印象填 Recall 或正确率没有意义。下一版应先建立最小评测集,再谈模型和参数升级。
RAGAS 把检索上下文、回答忠实度和生成质量分开看。ARES 也分别评估上下文相关性、回答忠实度和回答相关性,并用少量人工标注校准自动评估。这种拆法很适合工程排错。检索没找到依据时,换提示词救不了。来源找对了而答案仍乱说,问题才落到生成层。
评测集按真实问题分层
Section titled “评测集按真实问题分层”先从业务人员的真实问法里整理 100 到 300 条,优先覆盖已经发现的故障类型。
| 类型 | 示例 | 主要检查项 |
|---|---|---|
| 精确词 | 某个费用代码能否报销 | 关键词召回 |
| 同义改写 | 忘记打卡怎么补 | 语义召回 |
| 条件组合 | 入职四个月且绩效为 A 是否有奖金 | 多条件完整性 |
| 多证据 | 出差改签后住宿标准怎么计算 | 多片段召回 |
| 无答案 | 制度没有规定的问题 | 拒答 |
| 版本冲突 | 新旧制度表述不同 | 生效版本过滤 |
| 权限隔离 | 财务专用流程 | ACL 泄漏 |
每条样本至少记录问题、允许访问的知识库、应命中的片段 ID、可接受答案要点、禁止出现的结论和标注人。
{ "case_id": "hr_042", "question": "出差住宿超标后怎么处理", "allowed_kb_ids": ["kb_hr_policy"], "relevant_chunk_ids": ["chunk_108", "chunk_109"], "answer_points": ["超标审批", "个人承担条件"], "must_abstain": false, "forbidden_sources": ["docv_expired_2025"]}检索层先算硬指标
Section titled “检索层先算硬指标”Recall@K 检查前 K 个结果是否覆盖标注片段。MRR 关注第一个相关结果排得多靠前。多证据问题还要统计全部依据是否到齐。
def recall_at_k(retrieved: list[str], relevant: set[str], k: int) -> float: if not relevant: return 1.0 hits = relevant.intersection(retrieved[:k]) return len(hits) / len(relevant)
def reciprocal_rank(retrieved: list[str], relevant: set[str]) -> float: for rank, chunk_id in enumerate(retrieved, start=1): if chunk_id in relevant: return 1.0 / rank return 0.0比较 Chunking、Embedding、Hybrid Search 和 rerank 时,固定同一份评测集,记录配置版本。每次只改一个主要变量,否则结果提高了也不知道原因。
回答层检查四件事
Section titled “回答层检查四件事”| 指标 | 要回答的问题 |
|---|---|
| 忠实度 | 答案中的结论能否被返回来源支持 |
| 引用准确率 | 每个引用是否真的支持对应结论 |
| 引用召回率 | 关键结论是否都有引用 |
| 拒答准确率 | 无材料时有没有停下来,有材料时有没有误拒答 |
再加一项安全指标,未授权片段命中数必须为零。它应该作为发布门槛,不能和普通质量分平均。
LLM-as-judge 可以降低回归测试成本,但要先用人工样本校准。每轮随机抽查,保留模型评分和人工评分的分歧。涉及财务、人事和合规的样本仍需要业务人员确认。
线上指标负责发现新问题
Section titled “线上指标负责发现新问题”离线集保证旧问题不反复,线上日志寻找评测集没有覆盖的问法。建议记录这些字段。
- 检索配置版本、Embedding 版本和生成模型版本
- 候选片段 ID、最终送入模型的片段 ID 和引用 ID
- 解析、检索、rerank、生成各阶段耗时
- 输入输出 token、调用成本和错误码
- 回答状态、用户反馈和人工纠错结果
日志里涉及员工问题和内部文件内容,采集范围要经过数据合规确认。能用 ID 和摘要完成排错时,不要默认保存完整问题与上下文。
几个最容易踩的坑
Section titled “几个最容易踩的坑”权限表建了却没有进入查询
Section titled “权限表建了却没有进入查询”数据模型里有 Permission 只说明未来准备做权限。验收要从一个无权限账号发起真实查询,检查召回候选、生成上下文和日志里都没有越权片段。
API 进程承担长任务
Section titled “API 进程承担长任务”asyncio.create_task 不会替你保存任务状态。进程退出、代码热更新和容器迁移都可能中断索引。重任务放到持久队列或任务表,API 只返回任务 ID。
自定义 Embedding 只有核心片段
Section titled “自定义 Embedding 只有核心片段”原文的 AliyunEmbedding 省略了 LlamaIndex 要求的同步和异步方法,也在异步服务里使用同步 httpx.Client。这样的代码适合解释请求结构,不适合作为可直接运行的实现。实际封装要覆盖批次限制、超时、重试、限流、空输入、维度校验和供应商错误码。
阿里百炼目前允许 text-embedding-v3 和 text-embedding-v4 显式指定维度,默认值为 1024。模型名、维度、输入上限和 query/document 类型都应从配置和官方文档确认,不能只凭旧代码注释。
引用停在文件名
Section titled “引用停在文件名”用户点击引用时应打开具体版本和位置。原文件被替换后,旧回答仍要能找到当时的版本。对象存储中的原件和解析产物需要不可变地址,删除策略也要和审计期限一致。
开发用 Compose 被当成生产配置
Section titled “开发用 Compose 被当成生产配置”挂载本地源码、使用 uvicorn --reload、把 API 与索引任务放在同一容器,适合本地开发。生产镜像应在构建时复制代码,关闭 reload,让 API 和 worker 独立伸缩。数据库迁移在接流量前执行,容器还要提供 readiness、liveness 和优雅退出。
FastAPI 的容器部署文档建议在 Kubernetes 等编排环境里由集群复制容器,通常每个容器运行一个 Uvicorn 进程。单机 Docker Compose 可以采用不同配置,关键是明确谁负责重启、扩容和负载均衡。
上线前按这个顺序补
Section titled “上线前按这个顺序补”如果沿着现有 MVP 继续做,改造顺序可以压成五步。
- 接入真实认证,把 ACL 过滤写进检索语句,补跨租户端到端测试
- 用持久任务替换进程内任务,完成幂等索引和文档版本切换
- 建立第一批人工评测集,保存当前 Dense Retrieval 基线
- 改造结构化解析、片段元数据和可点击引用
- 根据失败样本决定是否加入 Hybrid Search、rerank 或查询分解
前两步解决安全和数据一致性,第三步建立后续迭代的尺子。没有这把尺子,换模型、调 top_k 和增加 Agent 流程都只能凭感觉。
做到这里,这个系统才从能回答问题的 Demo 进入可小范围试用的内部工具。生产开放还需要容量测试、备份恢复、密钥管理、审计留存和业务验收。它们属于另一层证据,不能用本地 Docker 启动成功代替。