最近一直在调研市面上主流的 RAG 开源框架,发现或多或少都存在短板:检索召回不准、缺少文档溯源、缺少后台管理、二次改造成本很高。
索性决定自己从零动手,基于 FastAPI + LangGraph + PGVector 开发一套私有化知识库项目,代号辞溯。本篇记录第一期整体架构、技术选型、开发中踩过的坑,欢迎同行交流提意见。
一、产品能力全演示
整套系统可直接运行,支持功能演示,能够落地投产,并非仅停留在设计阶段的理论方案。
1. 极简企业登录页
辞溯采用商务极简风格设计,聚焦企业使用场景,主打知识沉淀·精准检索·有据可依的核心价值。支持账号密码安全登录,内置权限校验机制,从入口杜绝匿名访问风险,适配企业内部办公的安全规范。
2. 多知识库隔离工作台(核心企业能力)
很多 RAG Demo 只支持单一知识库,无法满足企业多业务域隔离需求。辞溯支持多知识库独立管理,可按业务域拆分:技术文档库、行政制度库、业务流程库、新人培训库。
各知识库的数据、权限、成员相互隔离,避免知识混杂与跨库访问风险,实现知识资产分区管控。工作台直接呈现文档数量和知识库描述,方便运营维护。
3. 全流程文档生命周期管理
很多 RAG 系统缺少文档发布流程,文件上传后直接进入知识库检索,存在合规风险。辞溯搭建完整文档流转机制: 批量上传 → 格式校验 → 解析处理 → 草稿留存 → 审核发布 → 上线检索问答
支持 PDF、DOCX、TXT、Markdown 主流文档类型,单次批量上传上限 20 份,适配批量导入场景。 文档通过状态隔离实现管控:只有发布状态的文档才会进入检索链路参与问答;草稿文档仅内部可见,不会被检索调用。配合文档标签、状态管理能力,让大规模知识沉淀更加规范可控。
4. 流式智能问答+精准溯源(RAG核心价值)
传统 RAG 方案多直接调用大模型生成回答,容易出现内容无依据、产生幻觉,结果可信度不足。本系统基于 LangGraph 编排完整问答状态机,打造可控、可追溯、低幻觉的企业级问答能力:
- 支持多轮上下文记忆,可理解自然语言追问,自动补全省略式问题语义;
- 前端流式逐字返回答案,交互体验对标主流商用 AI 产品;
- 强制溯源绑定:回答内容关联对应的文档来源、原文切片及相关度评分;
- 幻觉管控机制:知识库不存在对应信息时,如实反馈暂无相关依据,不编造内容。
以此改善企业 AI 问答中 “回答通顺但缺少原文支撑” 的常见难题,保障问答结果有据可查、合规可信
5. 向量+关键词混合检索(精准度拉满)
企业检索往往存在两类痛点:仅靠向量检索,语义能力较好,但面对专业术语、制度编号、固定关键词时容易出现漏召;单纯关键词检索匹配精准,却难以理解自然语言的语义意图。
为此我搭建了多路召回加权融合方案:
最终分数 = 向量语义分数 × 0.7 + 关键词精准分数 × 0.3
尝试兼顾语义理解能力与关键词匹配精度。无论是自然语言的模糊提问,还是条款、编号类的精准查询,都希望做到秒级召回更合适的结果,更好适配企业多样化的检索场景。
6. 精细化成员权限管控(企业安全底线)
很多 RAG 方案缺少完善的权限模型,在企业多用户、多文档隔离场景下存在明显短板。辞溯采用双层权限隔离体系设计:
- 系统层权限:超级管理员拥有平台全局管理能力,普通用户只能访问分配给自己的资源;
- 知识库层权限:基于角色做权限隔离,区分所有者、编辑者、查看者,分别对应知识库管理、文档维护、检索问答权限。
权限校验全部下沉后端,前端不承担鉴权逻辑,从架构层面减少权限绕过、跨知识库越权查询的安全隐患,满足企业数据隔离要求。
7. 数据运营统计看板(赋能知识迭代)
企业知识库建设,一次性导入文档只是第一步,长期运营和知识迭代才是难点。系统提供可视化运营大盘,用于监控核心运营指标:
- 知识库、文档总量与累计问答统计;
- 用户点赞点踩反馈、7 天问答趋势;
- 热门问题 TOP 排行。
利用用户交互数据形成运营闭环:对高频问题补充文档,针对问答效果不佳的场景迭代检索策略,让企业知识资产可以不断生长、动态优化。
二、核心技术架构:极简但极致好用的企业方案
1.总体架构图如下:
2. LangGraph RAG状态机核心源码
包含历史对话加载、问句改写、混合检索、阈值校验、拒答分支、溯源绑定全逻辑,是企业可控RAG的核心骨架。
from typing import NotRequired, TypedDict
from uuid import UUID
from langchain_core.messages import HumanMessage, SystemMessage
from langgraph.graph import END, START, StateGraph
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.services.rag.providers import chat_client
from app.services.retrieval.hybrid import RetrievedChunk, hybrid_search
# 企业标准化无依据拒答话术
REFUSAL = "当前知识库中未找到足够依据,暂时无法回答这个问题。"
# RAG全局状态:统一全链路数据流转
class RAGState(TypedDict):
session: AsyncSession
user_id: UUID
knowledge_base_id: UUID
conversation_id: UUID
question: str
chat_history: list[dict[str, str]]
rewritten_query: NotRequired[str]
retrieved_chunks: NotRequired[list[RetrievedChunk]]
has_context: NotRequired[bool]
answer: NotRequired[str]
sources: NotRequired[list[dict]]
# 加载历史对话上下文
async def load_history(state: RAGState) -> dict:
return {"chat_history": state.get("chat_history", [])}
# 多轮问句自动改写:解决指代模糊、上下文断裂问题
async def rewrite_query(state: RAGState) -> dict:
history = state.get("chat_history", [])
client = chat_client()
if not history or client is None:
return {"rewritten_query": state["question"]}
compact_history = "\n".join(f"{i['role']}: {i['content']}" for i in history[-6:])
res = await client.ainvoke([
SystemMessage(content="将用户追问改写为可独立检索的完整问题,仅输出结果。"),
HumanMessage(content=f"历史对话:\n{compact_history}\n用户追问:{state['question']}")
])
return {"rewritten_query": str(res.content).strip() or state["question"]}
# 混合检索核心:PG原生全文检索 + 向量检索加权融合
async def retrieve(state: RAGState) -> dict:
chunks = await hybrid_search(
state["session"],
knowledge_base_id=state["knowledge_base_id"],
query=state.get("rewritten_query", state["question"])
)
return {"retrieved_chunks": chunks}
# 相似度校验:过滤低质量无效检索内容
async def assess_context(state: RAGState) -> dict:
chunks = state.get("retrieved_chunks", [])
return {"has_context": bool(chunks and chunks[0].score > 0.01)}
# 分支路由:有依据生成答案,无依据直接拒答
def route_context(state: RAGState) -> str:
return "generate" if state.get("has_context") else "refuse"
# 空上下文拒答节点
async def refuse(_: RAGState) -> dict:
return {"answer": REFUSAL, "sources": []}
# LLM合规生成:严格依赖上下文,杜绝幻觉编造
async def generate_answer(state: RAGState) -> dict:
chunks = state.get("retrieved_chunks", [])
context = "\n\n".join(f"[{idx}] {c.content}" for idx, c in enumerate(chunks, 1))
client = chat_client()
if client is None:
answer = "依据知识库内容:\n\n" + "\n\n".join(f"{i}. {c.content[:300]}" for i, c in enumerate(chunks[:3], 1))
else:
res = await client.ainvoke([
SystemMessage(content="仅依据上下文作答,禁止编造,不足如实说明,引用标注[1][2]编号。"),
HumanMessage(content=f"上下文:\n{context}\n问题:{state['question']}")
])
answer = str(res.content).strip()
return {"answer": answer}
# 组装溯源信息:支持审计、溯源、标签展示
async def build_sources(state: RAGState) -> dict:
sources = [
{
"chunk_id": str(c.chunk_id),
"document_id": str(c.document_id),
"document_name": c.document_name,
"snippet": c.content[:500],
"page_number": c.page_number,
"section_title": c.section_title,
"score": round(c.score, 4),
"tags": c.tags
}
for c in state.get("retrieved_chunks", [])
]
return {"sources": sources}
# 编译完整RAG状态机
def build_rag_graph():
graph = StateGraph(RAGState)
graph.add_node("load_history", load_history)
graph.add_node("rewrite_query", rewrite_query)
graph.add_node("retrieve", retrieve)
graph.add_node("assess_context", assess_context)
graph.add_node("refuse", refuse)
graph.add_node("generate", generate_answer)
graph.add_node("build_sources", build_sources)
graph.add_edge(START, "load_history")
graph.add_edge("load_history", "rewrite_query")
graph.add_edge("rewrite_query", "retrieve")
graph.add_edge("retrieve", "assess_context")
graph.add_conditional_edges("assess_context", route_context, {"generate": "generate", "refuse": "refuse"})
graph.add_edge("generate", "build_sources")
graph.add_edge("build_sources", END)
graph.add_edge("refuse", END)
return graph.compile()
rag_graph = build_rag_graph()
3. 数据库核心模型(权限+双检索支撑)
整套数据表设计完全贴合企业需求,支持多角色权限、双检索索引、标签元数据能力,是项目可投产的底层核心。
from __future__ import annotations
from datetime import datetime
from enum import StrEnum
from uuid import UUID
from pgvector.sqlalchemy import Vector
from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, Index, Integer, String, Text, UniqueConstraint, func
from sqlalchemy.dialects.postgresql import TSVECTOR, UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.config import settings
from app.db.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
# 业务角色与状态枚举
class SystemRole(StrEnum):
ADMIN = "admin"
USER = "user"
class MemberRole(StrEnum):
OWNER = "owner"
EDITOR = "editor"
VIEWER = "viewer"
class DocumentStatus(StrEnum):
UPLOADED = "uploaded"
PROCESSING = "processing"
READY = "ready"
FAILED = "failed"
class DocumentPublicationStatus(StrEnum):
DRAFT = "draft"
PUBLISHED = "published"
# 系统用户表
class User(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "users"
username: Mapped[str] = mapped_column(String(80), unique=True, index=True)
display_name: Mapped[str] = mapped_column(String(120))
password_hash: Mapped[str] = mapped_column(String(255))
system_role: Mapped[SystemRole] = mapped_column(Enum(SystemRole), default=SystemRole.USER)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
# 知识库主体(数据隔离核心单元)
class KnowledgeBase(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "knowledge_bases"
name: Mapped[str] = mapped_column(String(120), index=True)
description: Mapped[str | None] = mapped_column(Text)
created_by: Mapped[UUID] = mapped_column(PGUUID(as_uuid=True), ForeignKey("users.id"))
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
members: Mapped[list[KnowledgeBaseMember]] = relationship(back_populates="knowledge_base", cascade="all, delete-orphan")
# 知识库成员权限表(细粒度权限控制)
class KnowledgeBaseMember(Base):
__tablename__ = "knowledge_base_members"
__table_args__ = (UniqueConstraint("knowledge_base_id", "user_id"),)
knowledge_base_id: Mapped[UUID] = mapped_column(PGUUID(as_uuid=True), ForeignKey("knowledge_bases.id", ondelete="CASCADE"), primary_key=True)
user_id: Mapped[UUID] = mapped_column(PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), primary_key=True)
role: Mapped[MemberRole] = mapped_column(Enum(MemberRole))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
# 文档主表(支持草稿/发布灰度审核)
class Document(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "documents"
knowledge_base_id: Mapped[UUID] = mapped_column(PGUUID(as_uuid=True), ForeignKey("knowledge_bases.id", ondelete="CASCADE"), index=True)
original_name: Mapped[str] = mapped_column(String(255))
storage_path: Mapped[str] = mapped_column(String(500))
status: Mapped[DocumentStatus] = mapped_column(Enum(DocumentStatus), default=DocumentStatus.UPLOADED, index=True)
publication_status: Mapped[DocumentPublicationStatus] = mapped_column(Enum(DocumentPublicationStatus), default=DocumentPublicationStatus.DRAFT, index=True)
extracted_text: Mapped[str | None] = mapped_column(Text)
uploaded_by: Mapped[UUID] = mapped_column(PGUUID(as_uuid=True), ForeignKey("users.id"))
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
# 文档切片表(PG向量 + PG全文检索双能力支撑)
class DocumentChunk(UUIDPrimaryKeyMixin, Base):
__tablename__ = "document_chunks"
__table_args__ = (
UniqueConstraint("document_id", "chunk_index"),
# GIN索引加速PG全文检索匹配
Index("ix_document_chunks_fts", "content_tsvector", postgresql_using="gin"),
# HNSW索引加速向量相似度检索
Index("ix_document_chunks_embedding", "embedding", postgresql_using="hnsw", postgresql_ops={"embedding": "vector_cosine_ops"}),
)
document_id: Mapped[UUID] = mapped_column(PGUUID(as_uuid=True), ForeignKey("documents.id", ondelete="CASCADE"), index=True)
knowledge_base_id: Mapped[UUID] = mapped_column(PGUUID(as_uuid=True), ForeignKey("knowledge_bases.id", ondelete="CASCADE"), index=True)
chunk_index: Mapped[int] = mapped_column(Integer)
content: Mapped[str] = mapped_column(Text)
# PG原生全文检索向量,搭配ts_rank_cd实现关键词相关度排序
content_tsvector: Mapped[TSVECTOR] = mapped_column(TSVECTOR, nullable=False, comment="Postgres全文检索专用TS向量"),
# 向量语义检索
embedding: Mapped[list[float]] = mapped_column(Vector(settings.embedding_dimension))
page_number: Mapped[int | None] = mapped_column(Integer)
section_title: Mapped[str | None] = mapped_column(String(255))
# 自定义标签元数据,支持检索筛选
chunk_metadata: Mapped[dict] = mapped_column(JSONB, default=dict)
# 对话会话表
class Conversation(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "conversations"
user_id: Mapped[UUID] = mapped_column(PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), index=True)
knowledge_base_id: Mapped[UUID] = mapped_column(PGUUID(as_uuid=True), ForeignKey("knowledge_bases.id", ondelete="CASCADE"), index=True)
title: Mapped[str] = mapped_column(String(160))
4. 核心:PG双路混合检索源码(hybrid_search)
本项目最大亮点:不依赖ES,纯PG实现混合检索。一路向量语义召回、一路PG全文关键词召回,双路分数加权融合,完美互补单一检索的短板。
from dataclasses import dataclass
from uuid import UUID
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.models.entities import (
Document,
DocumentChunk,
DocumentPublicationStatus,
DocumentStatus,
)
from app.services.rag.providers import embed_query
@dataclass
class RetrievedChunk:
chunk_id: UUID
document_id: UUID
document_name: str
content: str
page_number: int | None
section_title: str | None
score: float
tags: list[str]
async def hybrid_search(
session: AsyncSession,
*,
knowledge_base_id: UUID,
query: str,
limit: int | None = None,
) -> list[RetrievedChunk]:
# 1. 向量语义检索:余弦距离匹配语义相似内容
query_embedding = await embed_query(query)
distance = DocumentChunk.embedding.cosine_distance(query_embedding)
base_filters = (
DocumentChunk.knowledge_base_id == knowledge_base_id,
Document.status == DocumentStatus.READY,
Document.publication_status == DocumentPublicationStatus.PUBLISHED,
Document.is_deleted.is_(False),
)
vector_rows = (
await session.execute(
select(DocumentChunk, Document.original_name, distance.label("distance"))
.join(Document, Document.id == DocumentChunk.document_id)
.where(*base_filters)
.order_by(distance)
.limit(settings.vector_candidate_k)
)
).all()
# 2. PG原生全文检索:ts_rank_cd 关键词相关度排序
ts_query = func.plainto_tsquery("simple", query)
rank = func.ts_rank_cd(DocumentChunk.content_tsvector, ts_query)
keyword_rows = (
await session.execute(
select(DocumentChunk, Document.original_name, rank.label("rank"))
.join(Document, Document.id == DocumentChunk.document_id)
.where(*base_filters, DocumentChunk.content_tsvector.op("@@")(ts_query))
.order_by(rank.desc())
.limit(settings.keyword_candidate_k)
)
).all()
# 3. 双路分数加权融合、去重合并
merged: dict[UUID, RetrievedChunk] = {}
# 向量分数加权
for chunk, document_name, raw_distance in vector_rows:
semantic = max(0.0, 1.0 - float(raw_distance or 1.0))
merged[chunk.id] = RetrievedChunk(
chunk_id=chunk.id,
document_id=chunk.document_id,
document_name=document_name,
content=chunk.content,
page_number=chunk.page_number,
section_title=chunk.section_title,
score=semantic * settings.vector_weight,
tags=chunk.chunk_metadata.get("tags", []),
)
# 关键词分数加权叠加
max_rank = max((float(row.rank or 0.0) for row in keyword_rows), default=1.0) or 1.0
for chunk, document_name, raw_rank in keyword_rows:
keyword_score = float(raw_rank or 0.0) / max_rank
if chunk.id in merged:
merged[chunk.id].score += keyword_score * settings.keyword_weight
else:
merged[chunk.id] = RetrievedChunk(
chunk_id=chunk.id,
document_id=chunk.document_id,
document_name=document_name,
content=chunk.content,
page_number=chunk.page_number,
section_title=chunk.section_title,
score=keyword_score * settings.keyword_weight,
tags=chunk.chunk_metadata.get("tags", []),
)
# 4. 总分倒序,截断TopK输出
final_limit = limit or settings.final_top_k
return sorted(merged.values(), key=lambda item: item.score, reverse=True)[:final_limit]
三、实战踩坑复盘(全是企业落地干货)
踩遍企业RAG落地的高频坑,每一条都是可直接复用的实战经验,帮你少走90%的弯路:
-
文档解析不稳定问题:不同格式文档存在乱码、空内容、扫描图片占位等问题,解决方案:分格式适配专属解析器,粗细双层切片策略,严格控制chunk长度与重叠度,保障切片质量稳定。
-
向量数据不一致问题:文档删除、下架、修改后易产生孤儿向量。解决方案:文档状态变更同步联动向量数据更新,草稿数据不写入向量库,保证业务与向量数据完全同步。
-
LLM幻觉泛滥问题:通过强约束Prompt+空上下文拒答策略,无依据不强行生成答案,配合溯源标注,彻底解决AI编造信息的问题。
-
多轮对话语义丢失问题:用户省略式追问极易导致检索偏差。解决方案:通过LangGraph节点自动补全上下文,将模糊追问转化为完整独立问题,精准匹配检索内容。
-
前后端契约不一致问题:接口迭代易引发字段报错。解决方案:接口变更同步更新Pydantic校验模型、TS类型定义,保证前后端契约完全统一。
四、项目总结与迭代规划
✅ 一期MVP核心价值总结
本次项目最大的突破,不是实现了AI问答,而是把RAG真正做进了企业业务里。
摒弃华而不实的模型堆叠,聚焦企业最刚需的安全、可控、可追溯、可运营四大核心能力,用极低的架构成本、极短的开发周期,落地一套可直接投产、可持续迭代的智能知识库系统,完美适配中小企业数字化落地场景。
二期迭代方向(持续升级中)
-
新增文档在线预览、版本管理、审批流能力;
-
接入飞书/企业微信文档同步,打通企业办公生态;
-
引入Rerank重排模型,进一步提升检索精准度;
-
支持多模型自由切换,适配不同业务问答场景;
-
完善审计日志导出、数据备份、高可用部署能力。
文末互动(欢迎交流)
目前辞溯一期主体功能已经完成,后续二期计划重点做 RAG 评测体系,引入 NDCG、Precision 指标,量化评估检索效果,持续优化混合检索策略。
想问下大家:你们在自研 RAG 落地的时候,遇到最头疼的问题是什么?是分块策略、检索召回,还是 LLM 幻觉控制?欢迎评论区一起交流讨论。