
前言

今天聊一个硬核的话题——知识库(RAG)和知识图谱是怎么设计的。
一提到 RAG,很多人第一反应就是"embedding + 向量数据库"。但这是入门,不是全部。生产级 RAG 系统要回答的问题多得多:
不同类型的文档(小说、法律、QA)能用同一种切片策略吗? 向量检索召回了,但用户问"张三和李四的关系是什么",向量检索答得了吗? 同时有 pgvector 和 Milvus,怎么选? 检索召回 top10 不准,有什么招?
LightBot 的知识库模块(lightbot-knowledge)把这些问题都答了一遍,而且答得很漂亮。今天我们就来深挖一下其底层是怎么做的。
项目链接:https://github.com/finch04/LightBot
一、知识库的四层模型:Knowledge → Document → Chunk → Embedding
知识库涉及四张表,四张核心表的职责分工:
| 表 | 职责 | 关键字段 |
|---|---|---|
knowledge | 知识库元信息 | embeddingModel, type(pg/milvus), graphEnabled, mindmapData, exampleQuestions |
document | 文档元信息 | filePath, fileHash, markdownPath, duplicateRate, version, lastEditTime |
chunk | 文档切片 | content, chunkIndex, tokenCount, metadata, status |
embedding | 向量存储 | chunkId, modelName, dimension |
四层不是简单的一对多堆叠,在细节中藏了不少工程巧思。
增量更新与去重:基于 fileHash 的差量重建
document 表里有 fileHash、duplicateRate、lastEditTime 几个字段,它们组合起来解决两个问题:
避免重复入库:同一份文档上传多次,通过
fileHash比对直接跳过避免全量重建:一份 1000 页的产品手册只改了几行,全量重新切片+向量化是几百块钱 API 费用和几分钟等待。
lastEditTime配合 chunk 级版本管理,可以只重切变化部分
duplicateRate 这个字段值得一提——它记录的是"文档内部 chunk 之间的重复率",用来诊断切片策略是否合理(重复率高说明重叠过大或者结构有冗余)。
content_tsv 全文索引:BM25 路的底座
chunk 表上挂了一个 GIN 索引(idx_chunk_content_tsv),配合 PG 触发器自动维护 tsvector。这个索引是后面混合检索里 BM25 全文检索路的底座。
很多 RAG 系统只走向量,完全忽略 PG 自带的全文检索能力。LightBot 保留它的原因后面会讲——纯向量召回在专有名词、产品型号这种"字面匹配"场景下吃亏。
二、文档切片:六种策略应对不同文档形态
切片是 RAG 的第一道关。切得不好,后面 embedding 再准也白搭。

整体套路是经典的策略模式 + 工厂路由:ChunkStrategy 接口定义 getType() 和 split() 两个方法,六个实现各管一种文档类型,ChunkStrategyFactory 在启动时把所有实现按 type 收集到 Map,运行时按入参 type 取对应策略,找不到就回退 general。
六个实现的核心差异:
1. GeneralChunkStrategy(通用)
递归分隔符切分 + token 合并 + 百分比重叠 + 超长硬切,这是 fallback 兜底策略。
关键参数 chunkTokenNum=512(每片目标 512 token)、overlappedPercent=10(片间重叠 10%,避免切断上下文)、delimiter="\n"。重叠上限 MAX_OVERLAP_TOKENS=200——这是个工程细节,防止极端情况重叠太多浪费存储。
2. SeparatorChunkStrategy(分隔符)
遇分隔符就切,不做合并。适合结构简单的纯文本。
3. BookChunkStrategy(书籍)
识别 Markdown 标题层级(#/##/###),还兼容中文标题"第一章"、"第一节"、"第一部分"。书籍类文档的本质是层级结构,按层级切才能保住章节语义。
4. LawsChunkStrategy(法律)
识别法律结构:"第X条"、"第X款"、"第X编"、"第X章"、"第X节"。法律文档的特性是条款之间高度独立,按条款切分可以让检索精确到具体法条。
5. QaChunkStrategy(QA)
识别 Q:/A: 或问答对模式,保持问答对完整。FAQ 文档必备——问题和答案分到两个 chunk 等于没用。
6. Fallback
任何不认识的 type 都回退到 general,保证系统健壮性。
为什么不用一种通用策略?
很多人觉得"recursive splitter 通杀",但实际跑下来:
小说用通用策略,会把对话和旁白切碎,检索时召回半句话
法律用通用策略,会把同一个法条的前后款切到不同片,完全失去语义
FAQ 用通用策略,问题和答案分到不同片,等于没用
策略多样化是 RAG 工程化的第一课:不是模型不行,是预处理没做好。
三、Embedding 向量化:pgvector + Milvus 双后端
切片完成,接下来是向量化。LightBot 同时支持 pgvector(PG 向量扩展)和 Milvus(专业向量数据库)。
路由逻辑在 EmbeddingServiceImpl.searchSimilarSql 里,核心思路:
用
routingCache(Map 结构)记住每个 knowledgeId 该走哪条路,避免每次查库读 typeshouldRouteToMilvus判断逻辑就是看knowledge.type字段——用户在创建知识库时根据规模选 pg 或 milvus选了 milvus 的库,首次写入时会触发
createCollectionForKnowledge建集合
为什么要双后端?
pgvector:适合中小规模(百万级向量以下),不用单独维护数据库,事务和 PG 主库一致,运维成本低
Milvus:适合大规模(亿级向量),有专门的 HNSW/IVF 索引,性能更好,查询也更精确。

LightBot 的设计哲学是让用户根据规模选,而不是替用户做决定。这是个产品层面的判断——技术选型本就该跟着业务规模走,平台层不该强行锁死。
pgvector 的 HNSW 索引调优
pgvector 用 HNSW 索引(vector_cosine_ops 算子类),建索引时两个参数:
m=16:每个节点的连接数,越大召回越准但内存占用越高ef_construction=200:构建时探索深度,影响索引质量
查询时还要动态调 ef_search,从默认 40 调到 100——HNSW 的 ef_search 越大召回越全但越慢,100 是生产验证过的平衡点。这是个非常细节的调优点,一般教程里不会讲,但对召回率影响明显。
一个已知限制
text-embedding-3-small 默认 1536 维,所以建表时 vector(1536) 是写死的。如果用户换了 embedding 模型(比如换 3072 维的),dimension 字段会动态写入实际维度,但向量列的维度是固定的——同一个库里换模型需要重建索引。这是个工程取舍:换模型本身是低频操作,不值得为它做动态维度。
四、RAG 检索流程:召回 → 融合 → 精排
RAG检索八步链路:
知识库校验 + 成员权限
参数解析
RagParamResolver—— topK(默认 5)和 threshold 的优先级:overrides > queryParams > config > 默认问题向量化
embeddingModel.call三路召回
embeddingService.searchSimilarSql—— 向量 / BM25 / 图谱RRF 融合 —— 三路结果按排名加权融合
Rerank(可选)
applyReranker上下文拼装 用
---分隔,加【文档名】前缀prompt 注入 + 模型调用
RAG_SYSTEM_PROMPT+chatModel.stream
重点在第 4-6 步——三路召回 + RRF 融合 + 可选精排,这是召回质量的核心。
五、三路召回 + RRF 融合:召回不准的解药

根据 search_mode 分发四种模式:
模式一:vector(纯向量)
走 pgvector 的余弦距离算子 <=>,适合纯语义匹配场景。
模式二:keyword(纯全文)
走 PG 的 plainto_tsquery 配合 content_tsv GIN 索引,适合专有名词、产品型号、错误码这种"必须字面命中"的查询。
模式三:hybrid(混合,默认推荐)
这是核心模式,算法是 RRF(Reciprocal Rank Fusion)。思路只有三步:
向量路召回
topK*3,BM25 路召回topK*3,各自排名对每个 chunk 计算
score = weight / (k + rank),k=60是平滑常数按 score 求和后排序,取 topK
为什么用 RRF 而不是加权分数?
向量相似度和 BM25 分数的尺度完全不同——向量是 0-1 的余弦距离,BM25 是无上限的相关性分数。直接加权会被尺度大的主导。RRF 只看排名,不看绝对分数,天然解决尺度问题。
公式 1/(k + rank) 里 k=60 是经验值,作用是平滑——第 1 名和第 2 名的权重差不会过大,避免头部结果垄断。
模式四(进阶):加入图谱召回
如果知识库启用了图谱(graphEnabled=true),还有第三路召回——图谱 PPR 检索。这是 LightBot 最有特色的设计,下一节详细讲。
六、知识图谱:Neo4j 双视图 + PPR 检索
向量检索有天花板——它只能找到"语义相似"的内容,回答不了"实体关系"类的问题。
比如用户问"张三和李四什么关系?",向量检索会返回所有提到"张三"或"李四"的 chunk,但它不知道这两人的关系是"父子"、"同事"还是"陌生人"。这种信息显式存在图谱里。
图谱构建
GraphExtractor 用 LLM 从 chunk 抽取实体关系三元组,实体类型约束在十种(人物/组织/职位/项目/产品/地点/技术/概念/事件/其他),关系开放抽取。
工程上有两个省钱细节:
多 chunk 合并:
extractBatch把多个 chunk 拼一起喂给 LLM,单次最多 6000 字符截断。100 chunk 的文档压到 10 次调用左右批量写入:Neo4j 用
UNWIND + MERGE批 50 条,MERGE保证幂等
双标签 schema:知识库隔离
每个节点带两层标签::Entity:kb_{knowledgeId}。这是为了知识库隔离——同一 Neo4j 实例可以存多个知识库的图谱,通过 kb_ 前缀标签过滤,Cypher 查询时 MATCH (n:Entity:kb_123) 就只查这个库的图。
双视图:single_doc vs merged
每个节点和关系都带 graph_source 字段,区分两种来源:
single_doc:单文档快照(从某个文档抽取出来的)merged:全库合并视图(所有文档抽完后融合的)
为什么要双视图?
想象场景:用户上传了 5 份文档,后来发现第 3 份有问题删了。如果是单视图,删除图谱节点会很复杂(要判断哪些节点是被多个文档共享的)。双视图下,直接删 graph_source='single_doc' AND document_id=X 的所有节点和关系,然后重建 merged 视图即可。
这是个典型的"用空间换可维护性"的设计——存储翻倍,但增量更新逻辑简单到一目了然。
图谱检索:PPR 算法
GraphRetrievalUtil 实现完整的 Personalized PageRank,流程四步走:
Milvus 找种子:用问题向量在三元组向量库里找最相似的几个,作为 PPR 的种子节点
Neo4j 2-hop 子图:从种子节点出发,
OPTIONAL MATCH1-2 跳邻居,构造子图PPR 迭代 15 次,damping 0.85
输出 按 PPR 分排序,取头部实体的描述和三元组描述
为什么用 PPR?
纯向量找实体:只能找到"和问题字面相似的实体"
纯子图展开:子图太大,信息过载
PPR:基于种子实体在子图上做"随机游走",和种子越紧密的实体分数越高
PPR 是 Google PageRank 的变种——PageRank 用全图作为转移概率,PPR 用一组种子节点作为"个人化偏好"。这个算法在推荐系统、社区发现里都用得很多。
图谱与向量融合
三路融合时,图谱结果用负数 chunk_id和向量/BM25 结果区分。这是个工程小技巧——避免引入额外的 type 字段,下游消费方通过 ID 正负号就能识别来源。
七、Reranker:精排的最后一公里
三路 RRF 融合完,还有可选的 DashScope Reranker 精排。

为什么不直接用向量召回 top5?
向量召回是"语义粗匹配",而 Reranker 是基于 Cross-Encoder 的"精排":
| 维度 | 向量召回(Bi-Encoder) | Reranker(Cross-Encoder) |
|---|---|---|
| 速度 | 快,毫秒级 | 慢,十毫秒级 |
| 精度 | 中 | 高 |
| 用法 | 海量初筛 | 少量精排 |
Bi-Encoder 把 query 和 chunk 分别编码成向量再算相似度,可以离线建索引;Cross-Encoder 把 query 和 chunk 拼成一句喂给模型,精度比 Bi-Encoder 高一截,但慢且没法离线建索引,所以只对 top10-30 候选做。
LightBot 的 Reranker 走 DashScope 的 gte-rerank API,通过 RerankerUtil 封装。use_reranker=true 时启用,默认不开(省钱)——这是个产品取舍:开 Reranker 提升精度但增加延迟和费用,让用户按场景选。
八、前端可视化:图谱怎么展示
@antv/g6:
核心组件分工:
KnowledgeGraphTab.vue:嵌入知识库详情页,工具栏含搜索节点、AI 抽取(单/多文档)、清空、语义搜索StandaloneGraph.vue:全局独立图谱页,用@antv/g6的Graph渲染 canvasAPI 入口
api/graph.js:getStandaloneSubgraph、importGraphFromJsonl、semanticSearchGraph
支持 JSONL 导入——批量建图谱时用得上。
九、踩过的坑
写到这里,分享几个我觉得值得说的工程细节:
1. 切片时不要重叠太多
很多人设 overlappedPercent=30,觉得"重叠多召回率高"。实测发现:
重叠多 → 同一段信息出现在多个 chunk → 检索时 top5 里 3 个是同一信息的重复 → 浪费上下文窗口
LightBot 默认 10%,上限
MAX_OVERLAP_TOKENS=200
2. LLM 抽取实体关系要限流
GraphExtractor.extractBatch 调 LLM 是付费的。一个 100 chunk 的文档,每个 chunk 抽一次就是 100 次调用。LightBot 用了多 chunk 合并(6000 字符截断),把 100 次压到 10 次左右。
3. 图谱检索的权重默认要低
graphWeight=0.3 是经验值。一开始我们设 0.5,发现图谱噪声多(抽取质量不稳),反而拉低整体检索效果。降到 0.3 让向量主导,图谱做辅助。
写在最后
很多人觉得 RAG 就是"调个 embedding API + 灌进向量库"。看完这篇我们了解到真正的 RAG 工程远比这复杂:
切片要分文档类型,法律、小说、FAQ 各有策略
检索要多路融合,向量 + 全文 + 图谱,RRF 排序
召回完要精排,Cross-Encoder Reranker
知识图谱解决关系类问题,PPR 算法是利器
全流程要状态化、可观测
如果你正在做 RAG 系统,我强烈建议你检查一下:
你的切片策略是不是只有一种?(场景覆盖不全)
你是不是只有向量召回?(无法适配更多向量检索模式)
你有 Reranker 吗?(重排序效果不一定强)
你能回答"实体关系"类问题吗?(不能的话考虑加图谱)
每一条改进都是召回率、准确率肉眼可见的提升。
我是 finch,我将继续介绍 LightBot 这套 AI Agent 平台一些亮点功能的底层设计思路。
这一系列还有评测体系、扩展机制、工作流引擎、SubAgent 并行委派等话题,关注公众号,加星标,后续不迷路。
你正在做的 RAG 系统遇到过什么坑?留言告诉我,下篇可能就写你想看的
夜雨聆风