1. 项目缘起与整体架构思路
1.1 为什么单靠向量库或图库都不够用
做过大模型应用的人多半踩过这个坑:用纯向量数据库做知识检索,问“某款设备的故障代码E42代表什么”能精准命中,但一旦问“这款设备的E42故障和上一代机型的同类问题有什么关联”,检索结果就开始胡言乱语。反过来,用纯图数据库做关联推理,关系链路清清楚楚,可用户用自然语言描述一个模糊场景时,图查询语句根本没法自动生成。
这个项目的出发点就是解决这个矛盾。核心思路一句话概括:向量数据库负责“找得到”,图数据库负责“理得清”,大模型负责“听得懂人话”。三者各司其职,通过一套编排层串起来。
具体来说,向量库(比如Milvus、Qdrant、Weaviate这类)擅长的是语义相似度匹配,把文本、图片甚至音频转成高维向量后做近似最近邻搜索,对模糊查询、同义表达、跨语言检索有天然优势。但它有个致命短板:它不理解实体之间的显式关系。你问“A和B是什么关系”,向量库只能告诉你“A和B的文本描述很像”,但说不清是父子、因果还是并列。
图数据库(比如Neo4j、NebulaGraph、JanusGraph)恰好补上这块。它以节点和边的方式存储实体与关系,做多跳查询、路径分析、社区发现非常高效。但它的查询语言(Cypher、nGQL)对普通用户不友好,而且没法直接处理“帮我找和这段话意思相近的所有文档”这种语义任务。
所以这个项目的架构逻辑是:用户自然语言输入 → 大模型做意图识别和查询改写 → 向量库做语义召回 → 图库做关系扩展和推理 → 大模型做结果融合与自然语言生成。整条链路下来,既保留了语义检索的灵活性,又拿到了关系推理的准确性。
1.2 整体架构分层设计
我把整个系统分成四层,从下往上依次是:
存储层:向量库存文本块、实体描述、关系描述的向量表示;图库存实体节点、关系边、属性。两边通过全局唯一的实体ID做关联。这里有个关键设计决策——向量库不存原始文本,只存向量和ID映射,原始文本放在对象存储或文档库里,避免向量库膨胀过快。
检索层:封装统一的检索接口,对外暴露“语义检索”“关系检索”“混合检索”三种模式。语义检索走向量库,关系检索走图库,混合检索先走向量库召回候选实体,再拿实体ID去图库做邻居扩展。
编排层:这是整个系统的大脑。用大模型做意图分类,判断用户问题属于“事实型”“关系型”还是“推理型”。事实型直接走向量检索,关系型走图查询,推理型走“向量召回+图扩展+大模型归纳”的组合拳。
应用层:对外提供问答接口、知识图谱可视化、关联推荐等能力。这一层不直接碰数据库,全部通过编排层调度。
注意:分层不是为了好看,是为了解耦。向量库和图库的选型后期大概率会换,编排层的逻辑也会迭代,分层之后每层可以独立替换,不会牵一发动全身。
1.3 技术选型的几个关键考量
向量库选型上,我对比过Milvus、Qdrant和Weaviate。Milvus生态最成熟,支持多种索引类型(IVF_FLAT、HNSW、DiskANN),适合数据量大的场景;Qdrant的过滤检索做得更优雅,payload里可以直接存结构化字段做条件过滤;Weaviate自带模块化向量化能力,但灵活性稍差。最终我选了Milvus,原因是它支持标量字段过滤和向量检索的混合查询,这在“先按实体类型过滤再走向量召回”的场景里非常关键。
图数据库选型上,Neo4j的Cypher查询语言最成熟,社区版免费,但集群版收费;NebulaGraph性能更强,适合超大规模图,但运维复杂度高。考虑到项目初期数据量在千万级节点以内,我选了Neo4j社区版,配合APOC插件做图算法。
大模型这块,编排层用的是一个7B参数级别的开源模型做意图分类和查询改写,生成层用更大的模型做最终答案合成。为什么不用一个模型全包?因为意图分类和查询改写对延迟敏感,7B模型推理快;最终生成对质量敏感,大模型更稳。分开部署还能避免一个环节出错导致整条链路崩溃。
2. 核心细节解析与实操要点
2.1 向量库的Schema设计与索引策略
向量库的Schema设计直接决定检索质量和性能。我设计的Collection包含以下字段:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | VARCHAR | 主键,与图库实体ID一致 |
| embedding | FLOAT_VECTOR | 文本向量,维度1024 |
| entity_type | VARCHAR | 实体类型标签,用于过滤 |
| source_doc | VARCHAR | 来源文档标识 |
| chunk_text | VARCHAR | 文本块内容,用于结果展示 |
| graph_node_id | VARCHAR | 关联的图节点ID |
索引策略上,我用了HNSW索引,参数设置是M=16,efConstruction=200。M控制每个节点的邻居数,越大召回率越高但内存占用越大;efConstruction控制建索引时的搜索深度,越大索引质量越好但建索引越慢。实测下来,M=16在千万级数据量下召回率能到95%以上,内存占用也可接受。
实操心得:建索引的时候一定要用
efConstruction调大一点,虽然建索引慢,但查询时的ef参数可以调小,整体查询延迟反而更低。我试过efConstruction=100和200的对比,后者建索引多花40%时间,但查询P99延迟降低了25%。
2.2 图库的节点与关系建模
图库的建模核心是实体识别与关系抽取。实体类型我定义了七类:人物、组织、产品、事件、地点、概念、文档。关系类型定义了十二种,包括“属于”“导致”“引用”“相似”“前后依赖”等。
节点属性设计上,每个节点至少包含:entity_id(全局唯一)、name(实体名)、entity_type(类型)、description(描述文本)、vector_id(对应向量库ID)。关系边上除了起止节点,还存relation_type、confidence(置信度)、source(来源文档)。
这里有个关键细节:关系的置信度不是拍脑袋定的。我用的是“抽取模型置信度 × 来源文档权重”的乘积。来源文档权重根据文档权威性预先设定,比如技术手册权重0.9,论坛帖子权重0.5。这样在推理时,低置信度的关系可以被过滤掉,避免噪声传播。
2.3 大模型在编排层的三个关键Prompt
编排层的大模型主要做三件事,每件事对应一个Prompt模板:
意图分类Prompt:输入用户问题,输出意图标签(事实型/关系型/推理型)。这个Prompt的关键是给足few-shot示例,我放了15个示例,覆盖各种边界情况。温度设为0.1,保证输出稳定。
查询改写Prompt:把自然语言问题改写成向量检索query和图查询语句。这里有个技巧——让模型同时输出向量query和Cypher模板,而不是分两步走。一步到位减少延迟,而且模型能看到全局信息,改写质量更高。
结果融合Prompt:把向量检索结果和图查询结果一起喂给模型,让它生成最终答案。这个Prompt里我强制要求模型标注每条信息的来源(来自向量库还是图库),方便后续做可信度评估。
注意:Prompt里的示例一定要用真实业务数据,不要用通用示例。我一开始用通用示例,模型在专业领域的意图分类准确率只有70%出头,换成业务示例后直接拉到92%。
2.4 数据同步与一致性保障
向量库和图库的数据同步是个容易被忽视的坑。我的方案是以图库为主库,向量库为从库。所有实体和关系的增删改先写图库,然后通过消息队列异步同步到向量库。
同步逻辑是:图库写入成功后,发一条消息到Kafka,消费者拿到消息后调用向量化服务生成向量,再写入向量库。这里有个幂等性设计——每条消息带唯一ID,向量库写入前先查这个ID是否已存在,避免重复写入。
一致性方面,我接受最终一致性,不追求强一致。因为知识检索场景对实时性要求没那么高,秒级延迟完全可以接受。如果追求强一致,就得用分布式事务,复杂度和性能代价都太大,不划算。
3. 实操过程与核心环节实现
3.1 环境搭建与依赖安装
先列一下我用的环境版本,避免版本不兼容的坑:
- Python 3.10
- Milvus 2.3.x(单机版,Docker部署)
- Neo4j 5.x(社区版,Docker部署)
- 大模型推理框架:vLLM 0.2.x
- 消息队列:Kafka 3.x
Milvus的Docker启动命令:
docker run -d --name milvus-standalone \ -p 19530:19530 -p 9091:9091 \ -v /data/milvus:/var/lib/milvus \ milvusdb/milvus:v2.3.0Neo4j的启动命令:
docker run -d --name neo4j \ -p 7474:7474 -p 7687:7687 \ -e NEO4J_AUTH=neo4j/password123 \ -v /data/neo4j:/data \ neo4j:5.11-communityPython依赖安装:
pip install pymilvus==2.3.0 neo4j==5.11.0 \ kafka-python==2.0.2 vllm==0.2.0 \ sentence-transformers==2.2.2实操心得:Milvus的Docker镜像一定要指定版本号,不要用latest。我有一次用latest拉到了2.4的beta版,API变了导致代码跑不起来,排查了半天。
3.2 向量化服务的实现
向量化服务负责把文本转成向量。我用的是BGE-large-zh模型,输出1024维向量。为什么选这个模型?因为它在中文语义相似度任务上表现稳定,而且推理速度可以接受(单条文本约15ms)。
核心代码逻辑:
from sentence_transformers import SentenceTransformer model = SentenceTransformer('BAAI/bge-large-zh-v1.5') def embed_text(text): # BGE模型建议加instruction前缀提升检索效果 instruction = "为这个句子生成表示以用于检索相关文章:" embedding = model.encode(instruction + text, normalize_embeddings=True) return embedding.tolist()这里有个细节:normalize_embeddings=True。归一化之后,向量内积就等于余弦相似度,Milvus里可以直接用IP(内积)度量,比用L2距离快。
批量向量化的时候,我设置了batch_size=32。太大显存扛不住,太小吞吐上不去。实测32在24G显存的卡上刚好跑满。
3.3 图数据导入与关系抽取
图数据导入分两步:先导入实体节点,再导入关系边。实体节点从业务数据库里抽,关系边用规则+模型结合的方式抽取。
规则抽取覆盖了结构化数据里的显式关系,比如数据库外键、文档里的超链接。模型抽取用的是一个小型关系抽取模型,对非结构化文本做处理。
Cypher导入语句示例:
// 批量导入实体节点 UNWIND $nodes AS node MERGE (n:Entity {entity_id: node.id}) SET n.name = node.name, n.entity_type = node.type, n.description = node.desc, n.vector_id = node.vector_id // 批量导入关系边 UNWIND $relations AS rel MATCH (a:Entity {entity_id: rel.from_id}) MATCH (b:Entity {entity_id: rel.to_id}) MERGE (a)-[r:RELATES {relation_id: rel.id}]->(b) SET r.relation_type = rel.type, r.confidence = rel.confidence, r.source = rel.source注意:MERGE语句在数据量大时性能很差,因为每次都要全图扫描。我的做法是先用CREATE批量导入,再建唯一约束和索引。导入完成后再建索引,比边导入边建索引快3倍以上。
3.4 混合检索的完整链路实现
混合检索的入口是一个统一的hybrid_search函数,接收用户问题,返回融合后的结果。完整链路如下:
第一步:意图分类。调用大模型判断问题类型。代码逻辑:
def classify_intent(question): prompt = f"""判断以下问题的类型,只输出一个词: 事实型:询问具体事实,如"X是什么" 关系型:询问实体间关系,如"X和Y有什么关系" 推理型:需要多步推理,如"X的问题会导致什么后果" 问题:{question} 类型:""" result = llm.generate(prompt, temperature=0.1, max_tokens=10) return result.strip()第二步:查询改写。根据意图类型,让大模型生成对应的查询。事实型生成向量query,关系型生成Cypher,推理型两者都生成。
第三步:并行检索。向量检索和图查询并行执行,用Python的concurrent.futures做并发。向量检索返回top_k=20的候选,图查询返回相关子图。
第四步:结果融合。把两路结果拼成一个结构化上下文,喂给大模型生成最终答案。Prompt里明确要求模型标注信息来源。
整个链路端到端延迟在800ms到1.5s之间,取决于问题复杂度和模型推理速度。对于需要多跳推理的问题,延迟会到2s左右,但用户体验上完全可以接受。
3.5 关联推理的实现细节
关联推理是这个项目最有价值的部分。举个例子:用户问“A产品的某个故障和B产品的某个设计缺陷有没有关联”。
纯向量检索只能找到分别描述A故障和B缺陷的文档,但没法建立两者之间的联系。纯图查询需要用户明确知道A和B之间的路径,但用户往往不知道。
我的做法是三跳扩展:先向量召回A故障相关的实体,再在图库里做两跳邻居扩展,找到与B缺陷相关的中间实体,最后用大模型判断这条路径是否构成有意义的关联。
具体实现上,图查询用变长路径匹配:
MATCH path = (a:Entity {entity_id: $start_id})-[*1..3]-(b:Entity) WHERE b.entity_type IN $target_types RETURN path, length(path) AS hops ORDER BY hops ASC LIMIT 50拿到路径后,把路径上的节点和边转成自然语言描述,喂给大模型做关联性判断。这里有个路径剪枝策略:置信度低于0.6的边直接跳过,避免噪声路径干扰。
实操心得:三跳扩展的候选路径数量会爆炸,一定要加LIMIT和置信度过滤。我一开始没加限制,一个查询返回了上万条路径,大模型根本处理不过来。后来加了置信度过滤和路径长度排序,候选控制在50条以内,效果反而更好。
4. 常见问题与排查技巧实录
4.1 向量检索召回率低的排查思路
这是最常见的问题。用户反馈“明明库里有相关文档,但就是搜不出来”。排查步骤我总结成一张表:
| 排查项 | 检查方法 | 常见原因 | 解决方案 |
|---|---|---|---|
| 向量模型 | 对比query和doc的向量相似度 | 模型不匹配 | 换用同一模型编码 |
| 索引参数 | 查看ef参数是否过小 | ef太小导致近似搜索漏召回 | 调大ef到128以上 |
| 文本预处理 | 检查是否有截断或清洗过度 | 关键信息被截掉 | 调整chunk大小 |
| 归一化 | 确认向量是否归一化 | 未归一化导致距离度量错误 | 统一做L2归一化 |
| 过滤条件 | 检查标量过滤是否过严 | entity_type过滤掉了正确结果 | 放宽过滤条件 |
我遇到过一次典型问题:召回率突然从95%掉到60%。排查后发现是数据同步时,向量化服务用的模型版本变了,新写入的向量和旧向量不在同一个语义空间。解决方案是固定模型版本,并且在向量库的Collection里存一个model_version字段,检索时只搜同版本的向量。
4.2 图查询超时的优化手段
图查询超时通常发生在多跳扩展场景。优化手段按优先级排列:
第一优先级:加索引。Neo4j里对entity_id和entity_type建索引,查询性能提升最明显。建索引的Cypher:
CREATE INDEX entity_id_index FOR (n:Entity) ON (n.entity_id); CREATE INDEX entity_type_index FOR (n:Entity) ON (n.entity_type);第二优先级:限制路径长度。把[*1..3]改成[*1..2],候选路径数量指数级下降。如果业务上确实需要三跳,可以分两次两跳查询拼接。
第三优先级:用APOC插件。APOC的apoc.path.expandConfig比原生Cypher的变长路径匹配快很多,而且支持更细粒度的控制。
第四优先级:图分区。如果图特别大,按entity_type做分区,查询时只扫相关分区。
注意:不要一上来就调大Neo4j的内存。我见过有人把堆内存调到64G,结果GC停顿反而更长。先加索引,再考虑内存。
4.3 大模型输出不稳定的应对策略
大模型在编排层输出不稳定,主要表现为意图分类错误、查询改写偏离、结果融合幻觉。应对策略分三层:
Prompt层:增加few-shot示例,降低温度,加输出格式约束。我强制要求模型输出JSON格式,解析失败就重试。
后处理层:对模型输出做校验。比如意图分类结果必须在预设标签集合内,Cypher语句必须能通过语法检查。校验失败就降级到规则兜底。
监控层:记录每次调用的输入输出,定期分析bad case。我每周跑一次bad case分析,把高频错误补充到Prompt示例里。
实测下来,这套组合拳能把大模型输出的稳定性从85%提升到97%以上。
4.4 数据同步延迟导致的不一致问题
前面说了用Kafka做异步同步,但异步就有延迟。用户刚写入的数据,可能几秒内查不到。这个问题在知识检索场景下通常可以接受,但如果业务要求实时性,就得做补偿。
我的补偿方案是双写+对账。写入图库的同时,同步写一份到Redis缓存,检索时先查Redis,命中就直接返回,未命中再走向量库。同时每天跑一次对账任务,对比图库和向量库的数据量,发现不一致就触发全量同步。
对账任务的逻辑:
def reconcile(): graph_count = neo4j.run("MATCH (n:Entity) RETURN count(n)").single()[0] vector_count = milvus.query("SELECT count(*) FROM entity_collection") if abs(graph_count - vector_count) > threshold: trigger_full_sync()阈值我设的是0.1%,超过就触发全量同步。全量同步在业务低峰期执行,避免影响线上查询。
4.5 关联推理结果不可信的过滤方法
关联推理最大的风险是给出看似合理但实际错误的关联。比如两个实体只是恰好出现在同一篇文档里,模型就推断它们有关联。
我的过滤方法分三步:
第一步:路径置信度过滤。路径上所有边的置信度乘积低于0.3的,直接丢弃。
第二步:来源多样性校验。如果一条关联路径只来自单一文档,可信度打折扣。要求至少两个独立来源支持同一条路径。
第三步:大模型自检。让大模型对生成的关联做二次判断,Prompt里明确要求“如果证据不足,输出‘无法确定’”。
这三步下来,关联推理的准确率从70%左右提升到90%以上。虽然会损失一些召回,但在知识推理场景下,准确性比召回率更重要。
4.6 性能瓶颈的定位与优化
系统跑起来之后,性能瓶颈通常出现在三个地方:向量检索、图查询、大模型推理。定位方法:
- 向量检索慢:看Milvus的查询延迟指标,如果P99超过100ms,考虑加索引或扩容。
- 图查询慢:看Neo4j的查询日志,找出慢查询,加索引或改写Cypher。
- 大模型推理慢:看vLLM的吞吐指标,如果GPU利用率低于60%,考虑增大batch_size或换更快的推理框架。
我遇到过一次性能问题:整体延迟从1s涨到5s。排查后发现是Kafka消费者积压,导致向量库写入延迟,进而影响了检索时的数据新鲜度。解决方案是增加消费者数量,并且把向量化服务改成批量处理,吞吐量提升了4倍。
5. 几个容易被忽视的工程细节
5.1 向量维度的选择不是越大越好
很多人觉得向量维度越高,表达能力越强,检索效果越好。实际不是。维度越高,索引越大,查询越慢,而且高维空间里的距离度量会变得不敏感(维度灾难)。
我对比过768维和1024维的效果。在中文知识检索任务上,1024维比768维的召回率高2%左右,但查询延迟高了40%,索引内存占用高了80%。性价比最高的是768维。如果业务对召回率极其敏感,再考虑1024维。
5.2 图库的边属性不要存大文本
图库的边属性里存大文本是个坑。Neo4j的边属性是存在关系记录里的,大文本会导致关系记录膨胀,查询性能急剧下降。
我的做法是边属性只存ID和元数据,大文本存到向量库或对象存储。比如关系的描述文本,存到向量库的chunk_text字段里,图库里只存一个引用ID。这样图库保持轻量,查询快,文本检索走向量库。
5.3 大模型Prompt的版本管理
Prompt是编排层的核心资产,但很多人不重视版本管理。改了一个词,效果可能天差地别,但过两天就忘了改了什么。
我的做法是Prompt文件化+版本号。每个Prompt存成一个独立的文本文件,文件名带版本号,比如intent_classify_v3.txt。每次修改都新建版本,不覆盖旧版本。同时在代码里记录当前使用的Prompt版本,方便回滚和对比。
5.4 监控指标的设计
监控不是越多越好,要抓核心指标。我重点监控四个:
- 检索召回率:每周人工标注一批query,计算召回率。低于90%就告警。
- 端到端延迟:P50和P99都要看。P99超过3s就告警。
- 大模型输出异常率:JSON解析失败、意图分类越界等异常的比例。超过5%就告警。
- 数据同步延迟:图库写入到向量库可查的时间差。超过10s就告警。
这四个指标覆盖了质量、性能、稳定性、一致性四个维度,足够发现大部分问题。
5.5 成本控制的几个手段
向量库和图库都是内存大户,大模型推理更是烧GPU。成本控制手段:
- 向量库用DiskANN索引:比HNSW省内存,代价是查询稍慢。数据量大的时候性价比很高。
- 图库冷热分离:热数据放内存,冷数据放磁盘。Neo4j支持页面缓存配置,把常用子图缓存在内存里。
- 大模型量化:用4bit量化,显存占用降到1/4,推理速度还略有提升。实测下来,量化后的模型在意图分类任务上准确率只掉了1%左右。
- 请求合并:多个用户的相似query合并成一次大模型调用,摊薄推理成本。
这套组合拳下来,整体成本比 naive 实现低了60%左右。
6. 后续可扩展的方向
这套架构跑通之后,扩展性其实很好。几个我试过或者正在试的方向:
多模态扩展:向量库支持图片和音频向量,图库支持多模态节点。用户上传一张设备照片,系统能检索到相关故障案例,并关联到维修手册里的对应章节。
实时增量更新:目前是准实时,后续可以做到秒级。关键是优化Kafka消费链路和向量化服务的吞吐。
联邦检索:多个向量库和图库联合检索,适合数据分散在不同系统的场景。编排层做结果合并和去重。
主动推理:不等用户提问,系统主动发现知识图谱里的异常关联并推送。比如发现某个故障模式在多个产品线上同时出现,自动生成预警。
这些方向我都在小规模验证,等跑通了再单独写一篇分享。目前这套向量库+图库+大模型的组合,在知识检索和关联推理场景下已经足够能打,推荐有类似需求的团队直接参考落地。