1. 从零搭建增强版智能知识库:为什么基础RAG不够用
做过RAG应用的人大概都有过这种体验:Demo阶段效果惊艳,一旦接入真实业务文档,回答就开始胡言乱语。用户问“第三季度华东区的退货政策有什么变化”,系统检索回来一堆“退货流程”“华东区介绍”“季度总结”的碎片,拼出来的答案驴唇不对马嘴。这不是模型不行,而是基础RAG的检索粒度太粗、语义理解太浅。
我这次要聊的“增强版智能知识库”,核心就是在标准RAG链路上做三件事:语义分块替代固定长度切分、混合检索替代单一向量召回、知识图谱补全实体关系。这三个改动听起来简单,但每一个都直接决定了知识库能不能从“玩具”变成“工具”。
先说清楚这个项目适合谁看。如果你已经跑通过LangChain的入门Demo,知道VectorStoreRetriever怎么用,但被以下问题困扰:文档切分后语义断裂、相似度检索召回率低、多跳问题答不上来、知识更新后索引重建成本高——那这篇内容就是写给你的。如果你还没接触过RAG,建议先补一下LangChain的基础链式调用,否则后面的内容会有些吃力。
整个项目的技术栈选型是这样的:LangChain做编排框架、Milvus做向量数据库、Neo4j做图数据库、BGE-M3做嵌入模型、语义分块用SemanticChunker。选LangChain不是因为它是唯一选择,而是它的生态最完整,从文档加载到检索链到Agent工具封装都有现成组件,省去大量胶水代码。Milvus选的是Standalone模式,单机部署够用,性能比Chroma高一个量级。Neo4j社区版免费,图查询语言Cypher上手快。
注意:不要一上来就追求“全都要”。我见过太多项目在初期就引入图数据库、重排序模型、多路召回,结果调试成本爆炸,最后连基础检索都没调好。建议先用语义分块+混合检索跑通,确认效果瓶颈确实在关系推理上,再引入知识图谱。
2. 语义分块:让文档切分不再“腰斩”语义
2.1 固定长度分块的致命缺陷
LangChain默认的RecursiveCharacterTextSplitter按字符数切分,比如chunk_size=500, chunk_overlap=50。这种切法在技术文档上问题不大,因为段落本身短。但遇到合同、研报、学术论文这类长段落文本,500字符可能刚好切在句子中间,导致“甲方应在收到货物后”和“30日内完成验收”被分到两个块里。检索时只召回前半句,模型自然答不出完整信息。
更隐蔽的问题是语义漂移。一个块里混了三个不同主题的句子,嵌入向量就成了“平均语义”,跟任何具体问题都不太像。这就是为什么很多知识库检索出来的内容“好像相关但又不太对”。
2.2 SemanticChunker的工作原理与参数调优
语义分块的核心思路是:先算相邻句子的嵌入相似度,在相似度骤降的地方切一刀。LangChain提供了SemanticChunker,底层逻辑是:
- 按句子边界拆分文本
- 计算每对相邻句子的嵌入余弦相似度
- 根据阈值策略(百分位、标准差、四分位距)确定切分点
- 合并相似句子成块
我实测下来,breakpoint_threshold_type选percentile最稳,阈值设95。意思是“相似度低于95%分位数的位置就切”。这个值不能太低,否则切得太碎;也不能太高,否则切得太粗。具体调参时,拿20篇典型文档跑一遍,看切出来的块平均长度是否在300-800字符之间,块内主题是否一致。
from langchain_experimental.text_splitter import SemanticChunker from langchain_community.embeddings import HuggingFaceBgeEmbeddings embed_model = HuggingFaceBgeEmbeddings( model_name="BAAI/bge-m3", model_kwargs={"device": "cuda"}, encode_kwargs={"normalize_embeddings": True} ) splitter = SemanticChunker( embed_model, breakpoint_threshold_type="percentile", breakpoint_threshold_amount=95, buffer_size=1, add_start_index=True )buffer_size=1表示比较相邻句子时前后各看一句,避免单句噪声导致误切。add_start_index=True保留原始位置信息,方便后续溯源。
2.3 分块后的元数据设计
分块不是切完就完事。每个块必须携带足够的元数据,否则检索回来你不知道它从哪来、属于哪个章节。我的做法是给每个块打上这些标签:
| 字段 | 说明 | 示例 |
|---|---|---|
| source | 原始文件名 | 2024Q3退货政策.pdf |
| section | 所属章节标题 | 第三章 华东区细则 |
| chunk_id | 块唯一标识 | doc_001_chunk_012 |
| prev_chunk | 前一块ID | doc_001_chunk_011 |
| next_chunk | 后一块ID | doc_001_chunk_013 |
| entities | 抽取的实体列表 | [华东区, 退货, 30日] |
prev_chunk和next_chunk是关键设计。检索命中某个块后,可以顺藤摸瓜把前后块一起拉出来,解决“答案被切断”的问题。这比简单加大chunk_overlap精准得多。
实操心得:语义分块的计算成本不低,一篇5万字文档用BGE-M3跑一遍大概要2-3分钟。建议离线处理好后把块和向量一起存库,线上检索时不再重复分块。另外,如果文档本身有清晰的标题层级(Markdown、HTML),优先用
MarkdownHeaderTextSplitter按标题切,再对超长章节做语义分块,两级策略效果最好。
3. 混合检索:向量召回+关键词召回+图查询三路并行
3.1 为什么单一向量检索不够
向量检索擅长语义匹配,但有两个硬伤。第一,专有名词和数字不敏感。用户问“XG-200型号的保修期”,向量检索可能召回“XG-100型号介绍”,因为两者语义接近。第二,精确匹配场景拉胯。用户输入一个合同编号,向量检索基本抓瞎。
所以增强版知识库必须做混合检索。我的方案是三路并行:
- 向量检索:Milvus做ANN近似最近邻,召回Top-20
- 关键词检索:BM25算法,用
rank_bm25库实现,召回Top-20 - 图查询:如果问题涉及实体关系,走Neo4j的Cypher查询
三路结果用Reciprocal Rank Fusion融合,公式是score = Σ 1/(k + rank_i),k取60。这个融合方式不需要归一化分数,直接看排名,工程上最稳。
3.2 Milvus索引参数怎么选
Milvus的索引类型直接决定检索速度和召回率。我对比过几种常用索引:
| 索引类型 | 召回率 | 检索速度 | 内存占用 | 适用场景 |
|---|---|---|---|---|
| FLAT | 100% | 慢 | 高 | 小数据集精确检索 |
| IVF_FLAT | 95%+ | 快 | 中 | 百万级通用 |
| HNSW | 98%+ | 很快 | 高 | 千万级高性能 |
| IVF_PQ | 85%+ | 极快 | 低 | 亿级内存受限 |
我选的是HNSW,参数M=16, efConstruction=200, efSearch=64。M控制每个节点的连接数,16是经验值,再大内存吃不消。efConstruction影响建索引质量,200够用。efSearch是查询时的搜索范围,64在召回率和延迟之间平衡得不错。实测100万条768维向量,单次检索延迟在15ms左右。
from pymilvus import Collection, CollectionSchema, FieldSchema, DataType fields = [ FieldSchema(name="id", dtype=DataType.INT64, is_primary=True, auto_id=True), FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=768), FieldSchema(name="text", dtype=DataType.VARCHAR, max_length=65535), FieldSchema(name="metadata", dtype=DataType.JSON) ] schema = CollectionSchema(fields) collection = Collection("knowledge_base", schema) index_params = { "index_type": "HNSW", "metric_type": "COSINE", "params": {"M": 16, "efConstruction": 200} } collection.create_index("embedding", index_params)3.3 BM25关键词检索的实现细节
BM25不需要额外服务,用Python库就能跑。关键是把所有块的文本预先分词、建倒排索引。中文分词用jieba,记得加载自定义词典,把业务专有名词加进去,否则“XG-200”会被切成“XG”“200”。
import jieba from rank_bm25 import BM25Okapi jieba.load_userdict("business_dict.txt") corpus = [jieba.lcut(chunk["text"]) for chunk in all_chunks] bm25 = BM25Okapi(corpus) def bm25_search(query, top_k=20): tokenized = jieba.lcut(query) scores = bm25.get_scores(tokenized) top_idx = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)[:top_k] return [all_chunks[i] for i in top_idx]BM25的k1和b参数默认1.5和0.75,一般不用动。如果发现长文档召回偏少,把b调到0.5左右,降低长度惩罚。
3.4 融合排序与重排序
三路召回各20条,融合后取Top-10,再送进重排序模型精排。重排序用BGE-Reranker-Large,交叉编码器结构,把query和doc拼在一起算相关性分数,比向量内积准得多。代价是慢,10条大概200ms,但值得。
from FlagEmbedding import FlagReranker reranker = FlagReranker("BAAI/bge-reranker-large", use_fp16=True) def rerank(query, candidates, top_k=5): pairs = [[query, c["text"]] for c in candidates] scores = reranker.compute_score(pairs, normalize=True) ranked = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True) return [c for c, s in ranked[:top_k]]注意:重排序模型很吃显存,Large版本大概需要2GB。如果部署在边缘设备,换Base版本,效果降幅在可接受范围内。另外,重排序的输入长度有限制,一般是512 token,超长块要先截断或分段打分再聚合。
4. 知识图谱补全:让知识库学会“关系推理”
4.1 什么场景需要图数据库
向量检索和关键词检索都是“找相似文本”,但有些问题本质是关系查询。比如“张三负责的项目里,哪些用了李四提供的组件?”这种问题,答案不在任何单个文档里,而是散落在项目文档、人员表、组件清单中。向量检索只能召回“张三负责项目A”“李四提供组件X”这些碎片,无法自动串联。
图数据库把实体当节点、关系当边,查询时沿着边遍历,天然适合多跳推理。Neo4j的Cypher查询语言写起来也直观:
MATCH (p:Person {name: "张三"})-[:负责]->(proj:Project)-[:使用]->(comp:Component)<-[:提供]-(p2:Person {name: "李四"}) RETURN proj.name, comp.name这条查询直接返回张三项目中李四提供的组件,不需要任何向量计算。
4.2 实体抽取与关系构建
图数据库的难点不在查询,在怎么把非结构化文本变成三元组。我的做法是用LLM做实体关系抽取,Prompt设计是关键:
从以下文本中抽取实体和关系,输出JSON格式: {"entities": [{"name": "", "type": ""}], "relations": [{"source": "", "target": "", "type": ""}]} 实体类型限定:人物、项目、组件、部门、政策 关系类型限定:负责、使用、提供、属于、适用于 文本:{chunk_text}用GPT-4或Qwen-Max跑一遍,准确率能到85%以上。抽完的三元组批量导入Neo4j,用MERGE语句避免重复节点。
from neo4j import GraphDatabase driver = GraphDatabase.driver("bolt://localhost:7687", auth=("neo4j", "password")) def import_triples(triples): with driver.session() as session: for rel in triples["relations"]: session.run( """ MERGE (a:Entity {name: $source}) MERGE (b:Entity {name: $target}) MERGE (a)-[r:REL {type: $type}]->(b) """, source=rel["source"], target=rel["target"], type=rel["type"] )4.3 图查询与向量检索的协同
不是所有问题都走图查询。我的路由策略是:先用LLM做意图分类,判断问题类型。如果是“是什么”“怎么操作”这类描述性问题,走向量+关键词检索;如果是“谁负责”“哪些关联”这类关系问题,走图查询;如果两者都涉及,并行执行后合并结果。
意图分类的Prompt很简单:
判断以下问题属于哪种类型,只输出类型名称: - 描述型:询问定义、流程、操作方法 - 关系型:询问人物、项目、组件之间的关联 - 混合型:同时涉及描述和关系 问题:{query}这个分类用GPT-3.5-turbo就够,延迟低,准确率90%以上。
实操心得:图数据库的Schema不要设计得太复杂。我一开始定义了十几种实体类型和关系类型,结果抽取时LLM经常混淆。后来精简到5种实体、5种关系,准确率明显提升。另外,Neo4j的索引要建在常用查询字段上,比如
Entity.name,否则大图查询会慢得离谱。
5. 常见问题与排查技巧实录
5.1 检索召回率低怎么排查
召回率低是最常见的问题,排查要按链路一步步来。先看分块是否合理:随机抽10个块,人工判断块内主题是否一致。如果块内混了多个主题,调低语义分块的阈值。再看嵌入模型是否匹配:中文场景用BGE-M3,英文用text-embedding-3-large,不要混用。最后看检索参数:HNSW的efSearch调大能提升召回,代价是延迟增加。
我整理了一个排查速查表:
| 现象 | 可能原因 | 解决方法 |
|---|---|---|
| 召回内容不相关 | 分块粒度过粗 | 调低语义分块阈值 |
| 专有名词召回失败 | 嵌入模型不敏感 | 增加BM25关键词召回 |
| 多跳问题答不上 | 缺少关系推理 | 引入图数据库 |
| 长尾问题召回少 | 索引参数保守 | 调大efSearch |
| 新文档检索不到 | 索引未更新 | 增量写入Milvus |
5.2 向量数据库性能优化
Milvus在数据量超过500万条后,查询延迟会明显上升。优化手段有几个:分区,按业务线把数据分到不同partition,查询时指定partition减少扫描范围;标量过滤,把常用的过滤字段(如文档类型、时间)建成标量索引,先过滤再向量检索;量化,用IVF_PQ把向量压缩,内存占用降为1/4,召回率损失5%左右。
# 分区查询示例 collection.load() search_params = {"metric_type": "COSINE", "params": {"ef": 64}} results = collection.search( data=[query_vector], anns_field="embedding", param=search_params, limit=20, expr='doc_type == "policy"', partition_names=["policy_partition"] )5.3 知识更新与增量索引
知识库不是建完就完事,文档会更新、会新增。全量重建索引成本太高,必须支持增量。我的做法是:新文档走一遍分块、嵌入、抽取流程,然后upsert到Milvus和Neo4j。Milvus支持按主键upsert,Neo4j用MERGE天然幂等。删除文档时,先按source字段查出所有相关块ID,批量删除。
注意:增量更新后,BM25的倒排索引也要同步更新。
rank_bm25不支持动态增删,我的做法是维护一个文档列表,每次更新后重建BM25索引。如果文档量大,换用Elasticsearch做关键词检索,支持实时增删。
5.4 Agent封装与工具调用
知识库最终要作为Agent的一个工具来用。用LangChain的Tool封装检索接口:
from langchain.tools import Tool def knowledge_search(query: str) -> str: # 意图分类 intent = classify_intent(query) if intent == "关系型": results = graph_search(query) elif intent == "描述型": results = hybrid_search(query) else: results = merge(hybrid_search(query), graph_search(query)) return format_results(results) knowledge_tool = Tool( name="knowledge_base", func=knowledge_search, description="查询企业内部知识库,输入自然语言问题,返回相关文档片段和关系信息" )Agent的System Prompt要写清楚什么时候调用这个工具:
你是一个企业知识助手。当用户询问公司政策、项目信息、人员关系时,调用knowledge_base工具。 如果工具返回的结果不足以回答问题,明确告知用户“知识库中未找到相关信息”,不要编造。5.5 安全与权限控制
知识库往往涉及敏感信息,权限控制不能少。我的方案是在元数据里加access_level字段,检索时根据用户角色过滤。Milvus的标量过滤支持表达式,比如access_level <= 3。图数据库那边,在关系边上加department属性,查询时限定部门。
# 带权限过滤的检索 user_level = get_user_level(user_id) expr = f'access_level <= {user_level}' results = collection.search(..., expr=expr)实操心得:权限过滤一定要在检索层做,不要等结果返回后再过滤。否则用户可能通过召回内容的相似度反推出敏感信息的存在。另外,Agent的对话历史里不要存敏感原文,只存摘要或引用ID。
6. 从Demo到生产:我踩过的那些坑
第一个坑是嵌入模型的维度不一致。我一开始用text-embedding-ada-002建库,后来换BGE-M3,维度从1536变成768,整个库要重建。教训是:选嵌入模型前先确认业务场景的语言和领域,中文为主就直接上BGE系列,别中途换。
第二个坑是语义分块的计算时间。一篇10万字的文档,用BGE-M3逐句算相似度,跑了将近10分钟。后来改成先按段落粗切,再对超长段落做语义分块,时间降到2分钟。分块策略要分层,不要一刀切。
第三个坑是图数据库的实体对齐。“张三”和“张先生”被抽成两个节点,“XG-200”和“XG200”也是。解决方法是抽取后做一轮实体归一化,用编辑距离或嵌入相似度合并同义实体。这一步不做,图查询结果会大量遗漏。
第四个坑是重排序模型的延迟。BGE-Reranker-Large在CPU上跑10条要2秒,完全不可接受。后来上了GPU,降到200ms。如果实在没有GPU,用Base版本或者Cohere的Rerank API,延迟和效果折中。
第五个坑是Agent的工具调用死循环。Agent有时候会反复调用knowledge_base工具,每次query略有不同,但都答不上来。解决方法是在Prompt里加最大调用次数限制,或者在工具函数里做去重,相同语义的query直接返回缓存结果。
这个增强版知识库目前跑了三个月,接入了大概2000份文档,日均查询300次左右。检索准确率从基础RAG的62%提升到87%,多跳问题的回答准确率从几乎为零提升到71%。效果提升最明显的还是语义分块和混合检索这两步,图数据库的收益主要在关系密集型场景,如果业务文档以操作手册为主,可以先不上图数据库,把前两步做扎实。
后续我打算把重排序模型换成更轻量的Cross-Encoder,再试试用LLM做查询改写,把用户的口语化问题转成更适合检索的形式。知识库这东西,没有一劳永逸的方案,得跟着业务数据不断调。