跳转到内容

企业内部知识库的 RAG 落地记录

以员工制度问答为例,拆开文件入库、权限过滤、检索生成、引用溯源、离线评测和生产部署中的具体问题。

前一版文章把框架、模型、数据库和部署都讲了一遍,读完却很难回答一个实际问题。公司要做员工制度问答,第一版到底该做什么,哪些设计会在上线前返工。

这次把范围收紧,只看一个场景。员工在内部系统询问考勤、报销、休假和奖金制度,系统从正式文件中找依据,给出带出处的回答。没有足够材料时明确停下来。不同部门的文件不能串库,旧制度也不能混进新答案。

本文整理自一个 RAG MVP 的设计记录。已经跑通的部分包括文件上传、轻量解析、向量索引、检索生成和来源返回。权限表还没有进入查询路径,索引任务仍在 API 进程内运行,多知识库结果也没有 rerank。后文给出的改造方案用于说明下一版怎么做,不代表这些能力已经上线。

如果需要先补术语和选型背景,可以看 RAG 技术全景与选型。这篇只处理落地过程。

内部知识库很容易被做成一个能聊天的搜索框。演示时看着完整,实际验收会卡在几个很朴素的问题上。

  • 回答能否定位到文件版本、章节和页码
  • 没有依据时能否拒答
  • 两份制度冲突时能否把冲突交给用户判断
  • 用户能否搜到自己无权查看的片段
  • 新版文件入库失败时,旧版是否还能正常提供服务
  • 模型、切分方式或检索参数变化后,效果是否真的提高

第一版的交付标准可以收敛成四条。

有据可查

每个关键结论都要绑定来源。来源至少包含文件 ID、不可变版本号、章节路径、页码和片段 ID。只返回文件名不够,同名文件和多版本文件会让引用失去意义。

无据停答

系统找不到足够材料时返回 insufficient_evidence。模型可以说明缺少什么材料,不能靠常识补齐内部制度。

权限先于检索

用户身份由服务端认证结果提供。查询范围由权限服务计算。客户端传来的知识库列表只能缩小范围,不能扩大范围。

版本切换可恢复

新版本完成解析和索引后再切为生效状态。失败任务允许重试,重复执行不会产生重复片段,服务重启也不会丢任务。

这四条会直接决定后面的数据模型和服务拆分。它们比先选 LlamaIndex 还是 LangChain 更早。

现有 MVP 的处理路径很短。

上传文件
-> 保存对象存储
-> 写入文件记录
-> 解析 PDF、TXT、Markdown 或 CSV
-> 生成向量并写入 pgvector
-> 按相似度取回片段
-> 交给 LLM 生成回答
-> 返回答案和片段文本

它适合确认模型接口、数据库和基本问答能否接通,也暴露了五个需要优先处理的问题。

现状直接后果
查询接口接收客户端传入的 user_id身份可以伪造,权限表即使存在也没有约束力
asyncio.create_task 执行索引API 重启后任务丢失,多副本部署时状态难以追踪
每个知识库动态创建一张向量表迁移、监控和模型升级都会随表数量变复杂
多知识库结果直接比较向量分数不同索引和不同模型的分数未必处在同一尺度
来源只有文件名和片段文本文件更新后无法证明答案来自哪个版本

所以这一版仍是技术原型。它证明基本问答可以接通,没有证明系统可以安全地交给员工使用。

规模不大时,PostgreSQL 加 pgvector 足够同时承载业务记录和向量。对象存储保留原文件,API 处理认证和查询,独立 worker 负责解析与索引。

浏览器或业务系统
|
v
API 服务
|-- 认证、权限、查询、引用组装
|-- 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_documentsembed_query 分开保留了检索模型的非对称用法。百炼的官方文档也建议在检索任务中区分 query 和 document。换供应商时,业务层不需要知道 HTTP 字段和批次限制。

原来的 KnowledgeBaseFilePermission 三类记录不足以支撑更新、重试和审计。更稳妥的最小模型包含这些表。

主要职责
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 重跑时可以 UPSERTdocument_version_idis_active 让新旧版本短暂共存。section_path 与页码负责引用定位。embedding_modelembedding_version 用来判断旧向量能否继续使用。

每个知识库一张向量表看起来隔离得很干净,知识库一多就会增加动态 DDL、迁移、索引监控和模型升级的成本。更常见的起点是让同一套 Embedding 配置共用一张 chunks 表,用 tenant_idkb_idis_active 过滤。这里的同一套配置包括模型、版本、维度和距离度量,只对上维度还不够。

这条规则也有边界。数据量很小或权限过滤非常挑剔时,精确检索加普通索引可能更合适。租户规模差异很大时,可以按租户或知识库分区。强隔离场景还可以使用独立表或独立数据库。pgvector 官方文档也把分区和独立表列为多租户隔离手段。

需要提前固定的是 Embedding 维度。向量列一旦定义为 vector(1024),写入其他维度会失败。模型升级先新建兼容的索引版本,完成回填和评测后再切流量,不要直接覆盖旧向量。

上传接口只负责接收文件、校验格式、保存原件并登记任务。文件解析往往需要几十秒,返回 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 -> queued

index_jobs 至少记录 statusattemptsavailable_atleased_untilworker_idlast_error。小团队可以用数据库任务表配合 FOR UPDATE SKIP LOCKED 领取任务。已有 Redis 或 RabbitMQ 时再接 Celery、RQ 或其他队列。选哪种队列没有数据模型重要,任务状态和幂等不能只放在内存里。

FastAPI 的 BackgroundTasks 适合响应后执行小任务。官方文档对重计算任务也建议考虑能跨进程、跨服务器工作的任务队列。文件解析和批量 Embedding 正好属于这一类。

文件更新时不要先删旧片段。可靠顺序如下。

  1. 上传原文件并计算校验值
  2. 创建 document_versions 记录,新版本暂不生效
  3. 解析、切分并写入新片段
  4. 校验片段数量、向量维度和必填元数据
  5. 在一个事务里停用旧版本,启用新版本
  6. 过一段保留期后清理旧向量和解析产物

新任务失败时,旧版本仍能回答。重复上传同一内容时,可以通过文件哈希和解析配置哈希判断是否需要重建索引。

制度文件很少是连续纯文本。标题层级、条款编号、表格、附件和脚注都会影响检索。

入库后先保存一个与模型无关的标准化文档结构。

{
"document_version_id": "docv_2026_003",
"blocks": [
{
"type": "paragraph",
"section_path": ["差旅制度", "住宿标准"],
"page": 8,
"text": "员工前往一线城市出差时……"
}
]
}

这一层保住标题、页码和阅读顺序。后面更换 Embedding 或切分参数时,可以直接从标准化结果重建,不必再次解析原 PDF。

内容类型初始切分方式容易出现的问题
制度条款按标题和条款边界切分,超长条款再按 token 切条件和例外被拆开
FAQ问题与答案保持在同一片段只召回答案,缺少问题语义
表格保留表名、表头和行标题,按行组展开数值离开列名后失去含义
长篇说明子片段检索,父段落送入模型上下文扩大后噪声增加
扫描 PDFOCR 后保留页码和版面块识别错误污染 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 临时推断。解析阶段拿不到准确页码时,引用就应该显示章节和片段位置,不能生成一个看似精确的页数。

权限校验只放在接口开头仍有漏洞。检索函数如果接收任意 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 distance
FROM chunks
WHERE tenant_id = :tenant_id
AND kb_id = ANY(:allowed_kb_ids)
AND is_active = true
ORDER BY embedding <=> :query_embedding
LIMIT :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 chunks
USING (
tenant_id = current_setting('app.tenant_id', true)::uuid
);

应用层权限过滤便于表达用户组和知识库授权,RLS 用来限制租户越界。两层都要有测试。测试用例至少包含无权限知识库、跨租户同名文件、空权限用户、权限刚撤销后的旧会话。

pgvector 的近似索引会先扫描索引候选,再应用过滤条件。租户或知识库过滤很严格时,LIMIT 10 可能拿不到十条结果。官方文档给出的处理手段包括提高搜索范围、启用 iterative scan、为低基数条件建部分索引,以及按高基数条件分区。

因此不能只在全量数据上测向量召回。评测查询要带上真实权限范围。数据还不大时,精确检索常常更省心。等延迟数据证明需要 HNSW,再比较召回损失和性能收益。

制度问答的第一条基线可以很简单。

用户问题
-> 生成 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 来合并全文和向量排名。这个建议能解释算法方向,是否适合当前知识库仍要用自己的评测集判断。

召回结果进入 LLM 前,还要处理这些细节。

  1. 相同文档、相邻位置的片段去重或合并
  2. 保留标题路径,让模型知道条款所属章节
  3. 同一问题命中新旧制度时,只使用生效版本
  4. 多份生效文件冲突时保留双方,并标记 conflict
  5. 按 token 预算截断,不能从条款中间硬切

上下文可以给每个来源分配稳定编号。

[S1]
document_version_id = docv_2026_003
section = 差旅制度 / 住宿标准
page = 8
content = ...

生成提示只允许引用提供的来源编号。响应解析后再验证每个引用编号是否存在。引用存在仍不代表结论受它支持,后面的评测要单独检查这一点。

向量相似度描述查询和片段在向量空间里的接近程度。它不等于答案正确概率,不适合直接显示成 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、上下文完整性和模型生成误差。

这次不补写一组漂亮数字。现有材料里没有保存人工标注集和完整运行日志,凭印象填 Recall 或正确率没有意义。下一版应先建立最小评测集,再谈模型和参数升级。

RAGAS 把检索上下文、回答忠实度和生成质量分开看。ARES 也分别评估上下文相关性、回答忠实度和回答相关性,并用少量人工标注校准自动评估。这种拆法很适合工程排错。检索没找到依据时,换提示词救不了。来源找对了而答案仍乱说,问题才落到生成层。

先从业务人员的真实问法里整理 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"]
}

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 时,固定同一份评测集,记录配置版本。每次只改一个主要变量,否则结果提高了也不知道原因。

指标要回答的问题
忠实度答案中的结论能否被返回来源支持
引用准确率每个引用是否真的支持对应结论
引用召回率关键结论是否都有引用
拒答准确率无材料时有没有停下来,有材料时有没有误拒答

再加一项安全指标,未授权片段命中数必须为零。它应该作为发布门槛,不能和普通质量分平均。

LLM-as-judge 可以降低回归测试成本,但要先用人工样本校准。每轮随机抽查,保留模型评分和人工评分的分歧。涉及财务、人事和合规的样本仍需要业务人员确认。

离线集保证旧问题不反复,线上日志寻找评测集没有覆盖的问法。建议记录这些字段。

  • 检索配置版本、Embedding 版本和生成模型版本
  • 候选片段 ID、最终送入模型的片段 ID 和引用 ID
  • 解析、检索、rerank、生成各阶段耗时
  • 输入输出 token、调用成本和错误码
  • 回答状态、用户反馈和人工纠错结果

日志里涉及员工问题和内部文件内容,采集范围要经过数据合规确认。能用 ID 和摘要完成排错时,不要默认保存完整问题与上下文。

数据模型里有 Permission 只说明未来准备做权限。验收要从一个无权限账号发起真实查询,检查召回候选、生成上下文和日志里都没有越权片段。

asyncio.create_task 不会替你保存任务状态。进程退出、代码热更新和容器迁移都可能中断索引。重任务放到持久队列或任务表,API 只返回任务 ID。

原文的 AliyunEmbedding 省略了 LlamaIndex 要求的同步和异步方法,也在异步服务里使用同步 httpx.Client。这样的代码适合解释请求结构,不适合作为可直接运行的实现。实际封装要覆盖批次限制、超时、重试、限流、空输入、维度校验和供应商错误码。

阿里百炼目前允许 text-embedding-v3text-embedding-v4 显式指定维度,默认值为 1024。模型名、维度、输入上限和 query/document 类型都应从配置和官方文档确认,不能只凭旧代码注释。

用户点击引用时应打开具体版本和位置。原文件被替换后,旧回答仍要能找到当时的版本。对象存储中的原件和解析产物需要不可变地址,删除策略也要和审计期限一致。

挂载本地源码、使用 uvicorn --reload、把 API 与索引任务放在同一容器,适合本地开发。生产镜像应在构建时复制代码,关闭 reload,让 API 和 worker 独立伸缩。数据库迁移在接流量前执行,容器还要提供 readiness、liveness 和优雅退出。

FastAPI 的容器部署文档建议在 Kubernetes 等编排环境里由集群复制容器,通常每个容器运行一个 Uvicorn 进程。单机 Docker Compose 可以采用不同配置,关键是明确谁负责重启、扩容和负载均衡。

如果沿着现有 MVP 继续做,改造顺序可以压成五步。

  1. 接入真实认证,把 ACL 过滤写进检索语句,补跨租户端到端测试
  2. 用持久任务替换进程内任务,完成幂等索引和文档版本切换
  3. 建立第一批人工评测集,保存当前 Dense Retrieval 基线
  4. 改造结构化解析、片段元数据和可点击引用
  5. 根据失败样本决定是否加入 Hybrid Search、rerank 或查询分解

前两步解决安全和数据一致性,第三步建立后续迭代的尺子。没有这把尺子,换模型、调 top_k 和增加 Agent 流程都只能凭感觉。

做到这里,这个系统才从能回答问题的 Demo 进入可小范围试用的内部工具。生产开放还需要容量测试、备份恢复、密钥管理、审计留存和业务验收。它们属于另一层证据,不能用本地 Docker 启动成功代替。