从零搭建企业级 RAG 知识库:DocMind 的架构设计与工程实战
深度研究多个同类知识库产品后,综合各家设计思路,从零搭建了一套纯 Python、配置驱动、渐进可演进的企业级 RAG 知识库系统。覆盖文档入库 → 向量召回 → 智能问答全链路,当前已完成 ~95 个端点中的 ~73 个。
一、背景与真实痛点
1.1 立项动机
在启动本项目之前,我们调研了多款企业级知识库产品(包括开源方案 Dify、FastGPT、LangChain-ChatGLM 及部分商业产品),发现几个共性短板:
- 向量召回的"静默失效":ES 客户端初始化时参数处理不当(如鉴权字段默认值问题),导致向量召回静默返回空结果,问答模块无法区分"真没内容"还是"检索挂了",直接输出空答案
- 多用户上下文隔离缺失:并发场景下不同用户的对话上下文互相污染,"问 A 得 B"的情况偶发
- Rerank 静默降级:精排模块因模型依赖缺失而悄悄 fallback,前端无感知,召回质量大打折扣
- 调试困难:多数产品将核心逻辑封装为闭源二进制模块,遇到深度问题只能靠日志盲猜
这些问题让我们意识到:与其在别人的架构里打补丁,不如吸取各家长处,从零设计一套可控、可调试、可演进的新系统。
1.2 核心决策:借鉴设计,从零搭建
在研究同类产品的过程中,我们系统梳理了各家在文档入库管线、向量检索、Prompt 设计上的取舍,形成了自己的设计蓝图:
- 梳理了完整的 API 端点矩阵(~95 个),覆盖知识库管理、文档入库、RAG 问答、KG 图谱的全场景
- 确定了 ES 向量索引的最佳 schema(dense_vector + nested 混合检索字段布局)
- 厘清了 sqlite 29 张业务表的关联关系和状态机设计
- 提取了经过验证的 Prompt 模板和 LLM 调用模式
目标项目:DocMind/ —— 纯 Python 从零搭建,复用现有 ES + sqlite 数据层(零迁移),完全自主可控。
二、系统架构:RAG 的企业级落地
2.1 为什么选 RAG
在技术选型上,我们明确拒绝了微调方案,原因很简单:
| 方案 | 根本问题 | 我们的情况 |
|---|---|---|
| 纯大模型微调 | 企业知识变化 → 重新训练 | 知识库每天有新文档入库 |
| RAG(检索增强生成) | 需要向量数据库和检索管道 | ✅ 已有 ES,适合复用 |
| 知识图谱 + 大模型 | 构建成本极高 | 作为高级功能后续补充 |
RAG 的核心价值在于:知识与模型解耦。更新文档不需要重新训练,向量索引重建即可生效,这与企业场景的实际需求高度吻合。
2.2 技术栈选择
我们的选型原则是"配置驱动、本地优先、生产可切换":
| 组件 | 本地开发 | 生产环境 | 切换方式 |
|---|---|---|---|
| Embedding | 本地 BAAI/bge-m3(1024维) | cpu-service 远程接口 | docmind.ini 改一行 |
| LLM | GLM-5.2(OpenAI 兼容协议) | Qwen 等任意兼容模型 | 改 llm_url + llm_model_name |
| 向量存储 | Elasticsearch(已有集群) | 同 ES,零迁移 | — |
| Rerank | bge-reranker-v2-m3(本地) | 同,模型缺失自动降级 | [rerank] enabled |
| 知识图谱 | Neo4j(可选) | Neo4j | [kg] enabled |
这种设计让本地开发不依赖公司内网,下周进公司只需改配置文件。
2.3 核心架构图
用户提问
│
▼
[embed_query] bge-m3 本地 / cpu-service 远程
│ ← docmind.ini [embedding] provider 切换
▼
[ES knn 召回] docchain_doc_vector_content
│ ← topic_id int→str(踩坑已修)
│ ← chunk_type 过滤(踩坑已修)
▼
[Rerank] bge-reranker-v2-m3 精排 top_m=5
│ ← 模型缺失自动降级,不影响主流程
▼
[Prompt 拼装] 经过多轮对比实验确定的 prompt 模板
▼
[LLM 生成] glm-5.2 / qwen(OpenAI 兼容)
│
▼
返回答案 + 命中 chunk 列表(可追溯)
三、从竞品研究中提炼的设计规范
3.1 Chunk Schema 设计
综合多家产品的向量索引设计,最终确定了一套兼顾语义检索和关键词匹配的 chunk schema:
# 综合 Dify / FastGPT / 商业产品的最佳实践提炼
CHUNK_SCHEMA = {
"vector": "dense_vector(1024, cosine)", # bge-m3 向量,1024维+余弦相似度
"content": "text", # 切块正文
"document": "text", # 文档名/标题,用于溯源展示
"doc_id": "keyword", # 文档 ID
"chunk_id": "keyword", # 切块 ID
"topic_id": "keyword", # 知识库隔离字段
"chunk_type": "keyword", # text/heading/summary/img/table
"tags": "nested(tag1..10)", # 多标签支持
"chunk_desc_nested": "nested(ik_smart, BM25)", # 为混合检索预留的 BM25 字段
}
3.2 两个重点规避的设计陷阱
研究同类产品时发现了两个高频踩坑点,在 DocMind 设计中做了显式规避:
# search/recall.py — 设计阶段就规避的两个常见问题
body = {
"knn": {
"field": VECTOR_FIELD,
"query_vector": qv,
"k": k,
"num_candidates": max(k * 3, 50),
},
"post_filter": {"bool": {"must": [
# 要点1: topic_id 在 ES 中建为 keyword 类型,filter 时必须显式传 str
# 如果传 int,ES 不会报错但 filter 静默失效,所有知识库的 chunk 混在一起被召回
{"term": {"topic_id": str(topic_id)}},
# 要点2: chunk_type 字段的设计名和使用名必须一致
# 避免出现 schema 里叫 chunk_type、代码里却用 type 导致 filter 不生效
{"terms": {"chunk_type": CHUNK_TYPES}},
]}},
}
3.3 Prompt 模板设计
Prompt 模板综合了多家产品的实测效果,最终沉淀了两个关键设计点:
# search/chat_doc.py — 经过多轮对比实验确定的最佳 prompt 模板
PROMPT_TEMPLATE = """根据提供的内容,专业、简洁地回答用户的问题。
如果无法从提供的内容中得到答案,就说 "不知道" 或 "没有足够的相关信息",不要试图编造答案。
----------------
数据:
{context}
----------------
问题:
{question}
----------------
答案:
"""
设计要点(对比实验结论):
- 单条 user message,无需 system 角色:实测发现 system prompt 在部分模型中会被弱化处理,单 user message 的指令遵循度更稳定
- 明确的兜底指令:
不知道就说不知道—— 这条指令在各模型上均显著降低了幻觉率,是 RAG 场景最有效的一句话
四、核心模块实现
4.1 三层可切换 Embedding 架构
# embedding/base_embedding.py — 抽象基类,所有 embedding 实现必须继承
from abc import ABC, abstractmethod
from typing import List
class Embedding(ABC):
@abstractmethod
def embed_query(self, text: str) -> List[float]:
"""单条查询向量化(召回时使用)"""
...
@abstractmethod
def embed_documents(self, texts: List[str]) -> List[List[float]]:
"""批量文档向量化(入库时使用)"""
...
# embedding/factory.py — 单例工厂,按配置切换 local/remote
_cache: Dict[Tuple, Embedding] = {}
def get_embedding(cfg: Config) -> Embedding:
key = (cfg.embedding.provider, cfg.embedding.model_name, cfg.remote.base_url)
if key not in _cache:
if cfg.embedding.provider == "remote":
from .remote_local_embedding import RemoteLocalEmbedding
_cache[key] = RemoteLocalEmbedding(cfg.remote.base_url)
else:
from .local_embedding import LocalBgeM3Embedding
_cache[key] = LocalBgeM3Embedding(cfg.embedding.model_name)
return _cache[key]
4.2 文档入库管线(Pipeline)
文档入库是"从 Demo 到可用产品"的关键一跃。我们设计了一套状态机驱动的异步管线:
# ingest/pipeline.py — 端到端入库,状态机推进
def run_pipeline(sqlite_path, es, embedding, *,
file_src_id, topic_id, file_path,
max_length=1000, overlap=100, redo=False) -> int:
"""
状态机: split_md_state(running→done), index_state(running→done/failed)
redo=True 时先删旧 chunk,实现文档更新
"""
try:
if redo:
delete_chunks(es, file_src_id) # 增量更新:先删旧 chunk
# 阶段1: 解析(md/txt 直读,pdf/docx 后续走 cpu-service)
text = parse_to_text(file_path)
# 阶段2: 切分
update_file_src_state(sqlite_path, file_src_id, split_md_state="running")
chunks = split_markdown(text, max_length=max_length, overlap=overlap)
update_file_src_state(sqlite_path, file_src_id, split_md_state="done")
# 阶段3: embed + 入 ES
update_file_src_state(sqlite_path, file_src_id, index_state="running")
n = index_chunks(es, embedding, doc_id=file_src_id,
topic_id=topic_id, chunks=chunks)
update_file_src_state(sqlite_path, file_src_id, index_state="done")
return n
except Exception as e:
# 失败状态写回 sqlite,前端可感知
update_file_src_state(sqlite_path, file_src_id,
index_state="failed", error=f"{type(e).__name__}: {e}")
raise
入库管线的完整设计(MVP 实现了前两步,后三步待补):
upload → convert_pdf → convert_md → split_md → summary → extract_kg → index
(LibreOffice) (cpu-service (TitleSplitter) (LLM) (Neo4j) (embed+ES)
版面/OCR)
4.3 Markdown 切分器
参考了多个开源项目的切分策略(LangChain RecursiveCharacterTextSplitter、LlamaIndex SentenceSplitter 等),我们实现了一个按语义边界切分的精简版切分器:
# ingest/splitter.py — 简化版 TitleSplitter,按语义边界切分
def split_markdown(text: str, max_length: int = 1000, overlap: int = 100) -> List[Dict]:
"""
切分策略:
1. 按 markdown 标题(#/##/###...)分章 → 保留标题作为 chunk 前缀(上下文保留)
2. 每章按段落累积切块(语义完整优先)
3. 单段超长走硬切(带 overlap 保持连续性)
"""
chunks = []
for heading, body in _split_into_chapters(text):
body = body.strip()
if not body:
continue
for piece in _chunk_text(body, max_length, overlap):
# 标题作为前缀附在块内容前,保留章节上下文
content = f"{heading}\n{piece}".strip() if heading else piece
chunks.append({
"content": content,
"chunk_type": "text",
"heading": heading or ""
})
return chunks
切分参数经验(实测调优):
| 参数 | 值 | 理由 |
|---|---|---|
max_length | 1000(字符) | 对应约 500 token,单块语义完整,不过长 |
overlap | 100(字符) | 硬切时保留上下文连续性,避免语义截断 |
| 切分边界优先级 | 章节 > 段落 > 硬切 | 尽量在语义边界切,保证召回质量 |
4.4 配置驱动(一个 ini 文件管全局)
# docmind.ini — 三个外部依赖全部配置驱动
[embedding]
# 本周本地开发用 local;下周进公司改 remote,其余代码不动
provider=local # local(bge-m3) | remote(cpu-service)
model_name=BAAI/bge-m3
[api]
# LLM:改 url + model_name 即可切换,统一走 OpenAI 兼容协议
llm_url=https://open.bigmodel.cn/api/paas/v4/chat/completions
llm_model_name=glm-5.2 # 改成 qwen-turbo 等任意兼容模型即可
[rerank]
enabled=true
model_name=BAAI/bge-reranker-v2-m3
top_m=5 # 精排后保留 top 5 个 chunk
[kg]
# 知识图谱可选功能,false 时 kg/* 端点返 not-enabled
enabled=true
uri=bolt://localhost:7687
五、踩坑与修复记录
5.1 开发过程中的典型踩坑
这些是我们在设计开发和集成过程中遇到的真实问题:
| # | 坑 | 现象 | 根因 | 修复方案 |
|---|---|---|---|---|
| 1 | ES 鉴权参数默认值 | 向量召回全部失败,返回 0 结果 | ES client 初始化时密码字段未正确传入 | get_es_client() 显式传 basic_auth=(name, password) |
| 2 | topic_id 类型 | 过滤器不生效,所有知识库 chunk 混召回 | ES 存的是 keyword(str),代码里传了 int | {"term": {"topic_id": str(topic_id)}} |
| 3 | 字段名不一致 | chunk 类型过滤失效 | schema 里是 chunk_type,代码里误用 type | 统一使用 schema 中的字段名 chunk_type |
| 4 | user_context 跨异步丢失 | 多用户上下文串串 | async 边界上下文传递机制不完善 | 采用 FastAPI 依赖注入管理用户上下文 |
| 5 | bge-m3 首次加载 ~2GB | 初始化等待 20 分钟 | 模型体积大,首次下载+加载慢 | 提前 huggingface-cli download 预缓存 |
| 6 | 向量不一致 | 新 embedding 跑 knn 召回不命中旧测试文档 | 旧 test 数据的 vector 是占位假数据 | 用真实 bge-m3 重注入测试文档(覆盖 vector 字段) |
5.2 三个关键设计决策
决策1:数据复用,零迁移
直接对接现有 ES 集群(43 个索引)和 sqlite(29 张表),不重建、不迁移。这让项目从 Day 1 就能在真实数据上验证效果,2 天内跑通 MVP。
决策2:模块化目录设计
DocMind/
├── config/ ← 配置加载(.ini 驱动)
├── auth/ ← 认证鉴权(RSA + JWT)
├── base_es/ ← ES 客户端管理
├── embedding/← 向量化抽象层(local/remote 可切换)
├── search/ ← 召回 + Rerank + 对话编排
├── router/ ← API 端点(FastAPI)
├── ingest/ ← 文档入库管线
├── kg/ ← 知识图谱(Neo4j)
└── core/ ← LLM 调用等核心能力
模块职责清晰、依赖单向(上层依赖下层),新成员接手单个模块时阅读半径不超过 3 个文件。
决策3:Rerank 降级而非崩溃
def _maybe_rerank(cfg, query, chunks):
"""模型不可用时优雅降级,不影响主流程"""
if not cfg.rerank.enabled or not chunks:
return chunks
try:
from search.rerank import get_reranker
return get_reranker(cfg.rerank.model_name).rerank(query, chunks, cfg.rerank.top_m)
except Exception:
# 模型缺失/加载失败 → 降级返回原序
return chunks
六、分阶段实施路线
当前完成度(截至 2026-06-27)
| 模块 | 端点数 | 状态 |
|---|---|---|
| AUTH(RSA 登录/JWT/menus) | 8 | ✅ |
| CHAT(流式 RAG + rerank + 持久化) | 1+持久化 | ✅ |
| 文档入库(upload/read/redo/delete + md/txt 解析) | 7 | ✅ |
| Chunk 管理(query/update) | 4 | ✅ |
| 主题/知识库管理 | 4 | ✅ |
| 对话历史 | 3 | ✅ |
| 系统管理 | 9 | ✅ |
| Prompt 管理 | 6 | ✅ |
| 评估(dataset + task) | 5 | ✅ |
| KG 知识图谱(Neo4j) | ~14 | ✅ |
| 合计 | 73/95 | ≈77% |
后续里程碑
M1 可用知识库(已达成)
└─ 应用内建库 + 传文档 + RAG 问答端到端通
M2 完整管理面(进行中)
└─ 用户/模型/参数/Prompt 完整管理
M3 质量与增强
└─ 评估系统 + 对话增强(rerank + 相似问题推荐)
M4 高级能力
└─ KG 图谱检索 + 以图搜图
M5 生产就绪
└─ 性能优化 + 容器化部署 + 安全基线
七、效果对比
7.1 RAG 对比纯大模型直答
| 维度 | 纯大模型(无 RAG) | DocMind RAG |
|---|---|---|
| 企业内部问题 | 无法回答(无训练数据) | ✅ 基于知识库准确回答 |
| 知识时效性 | 依赖训练截止日期 | ✅ 文档入库即生效 |
| 回答可追溯 | ❌ 无来源 | ✅ 返回命中 chunk 和 doc_id |
| 幻觉控制 | 容易编造 | ✅ Prompt 兜底 + 低 temperature |
| 知识更新成本 | 重新训练(极高) | 重建 ES 索引(极低) |
7.2 从零搭建 vs 基于开源框架魔改
| 维度 | 魔改开源框架 | DocMind 从零搭建 |
|---|---|---|
| 架构理解深度 | 浅层,依赖框架约定 | ✅ 每个模块亲自设计,理解透彻 |
| 问题定位能力 | ❌ 框架层问题难以深入 | ✅ 无闭源依赖,全程可控 |
| 功能扩展 | ❌ 受框架约束 | ✅ 完全自由,按需裁剪 |
| 定制化成本 | 高(需理解框架内部) | ✅ 低(自己的代码) |
| 团队知识沉淀 | ❌ 依赖框架文档和社区 | ✅ 代码即文档,成员可深入理解 |
| 长期维护 | ❌ 框架升级可能不兼容 | ✅ 完全自主掌控节奏 |
八、关键收获
8.1 RAG 实施的四个核心质量因子
-
切分粒度决定召回上限
max_length=1000+ 段落边界切分,比暴力按字数切效果好很多。标题作为 chunk 前缀保留,让跨章节问题也能召回到正确内容。 -
向量和过滤器都要对
向量召回只管语义相似度,业务隔离(不同知识库的 chunk 不互串)靠post_filter的topic_id过滤。两者缺一不可。 -
Rerank 是召回到回答的质量跃升
knn 召回 top 20,精排取 top 5。bge-reranker-v2-m3 的 cross-encoder 对中文语义匹配度远优于向量相似度单打独斗。 -
Prompt 设计比模型选择更重要
"不知道就说不知道"这条指令显著降低了幻觉率。低 temperature(0.3 左右)保证回答稳定可重复。
8.2 工程实施的三个关键原则
-
配置驱动胜过硬编码
embedding、LLM、ES 三个外部依赖全部通过 ini 文件切换。本地用 bge-m3,生产用 cpu-service,一行配置搞定,代码不动。 -
状态机让异步管线可观测
文档入库是耗时操作(embedding 大文档可能要几分钟),file_src表的split_md_state / index_state让前端可以轮询进度,失败时有明确的错误信息。 -
渐进交付优于大爆炸上线
不要一次性上线全部模块。我们的策略是:单模块开发 → 单测+集成验证通过 → 灰度上线。每步都有回滚预案,出问题只影响单模块。
九、后续规划
近期(P0):文档入库完整化
当前 MVP 只支持 md/txt,后续补全:
pdf/docx/pptx/xlsx
→ convert_md(cpu-service /v1/remote_image_structure)
→ OCR(/v2/detect + /v2/process,处理扫描件)
→ 表格/图片块特殊处理(写 docchain_doc_vector_img 等子索引)
中期(P1):混合检索
当前是纯向量检索,后续引入 BM25 关键词检索(ES 的 chunk_desc_nested 字段已为此准备):
def hybrid_search(query, top_k=5):
"""向量检索(语义)+ BM25(精确关键词),RRF 算法融合"""
vector_results = knn_search(query, top_k)
keyword_results = bm25_search(query, top_k)
return reciprocal_rank_fusion(vector_results, keyword_results)[:top_k]
远期(P3):知识图谱增强
KG 模块已完成(router/kg.py,~14 个端点),Neo4j 存储三元组关系。后续方向:
- 图谱辅助召回:用实体关系扩展检索范围(“A 依赖 B” → 问 B 时也召回 A 的相关内容)
- 子图可视化:直观展示知识点之间的关联
- Cypher 查询接口:支持结构化的图谱查询(已实现
kg/execute_cypher_query)
十、总结
这个项目最核心的一句话是:与其在别人的架子里修修补补,不如把各家的好设计学到手,从零搭一套自己能完全掌控的系统。
RAG 的技术本身并不复杂——Embedding + 向量检索 + 大模型生成,每个环节都有成熟方案。真正的挑战在于工程实施:
- 如何在现有数据基础设施上平滑落地(ES 集群 + sqlite 零迁移接入)
- 如何让向量召回的参数精确可靠(字段类型一致性、过滤逻辑正确性)
- 如何设计可观测的异步管线(状态机驱动,每阶段进度可追踪)
- 如何做到本地开发与生产部署的无缝切换(一套 ini 配置管全局)
下一步行动:推进阶段 2 的完整化——让 pdf/docx 文档也能入库,这是从"技术可用"到"业务可用"的关键门槛。
项目:DocMind/ — 纯 Python 企业级 RAG 知识库,配置驱动,渐进可演进
数据层:ES 集群(43 索引)+ sqlite(29 表),零迁移接入
当前进度:~73 / ~95 端点完成(≈77%),持续演进中

404

被折叠的 条评论
为什么被折叠?



