向量数据库+图数据库双通道检索:大模型RAG架构设计与工程实践
1. 项目缘起与整体架构思路1.1 为什么单靠向量库或图库都不够用做过大模型应用的人多半踩过这个坑用户问“A公司的创始人还投资过哪些同赛道的项目”纯向量检索能把“A公司创始人”相关的文档片段捞回来但“同赛道”“投资关系”这种多跳关联向量相似度根本表达不了。反过来纯图数据库擅长遍历关系可用户用自然语言描述一个模糊需求时图查询语句又没法直接生成。这个项目的出发点就是把两件事拼起来向量数据库负责“语义召回”图数据库负责“关系推理”。大模型在中间做调度员——把用户的话翻译成检索意图再把两边的结果融合成一段有依据的回答。我把它拆成三层接入层对话与意图解析、检索层向量图双通道、融合层结果重排与推理链生成。这个分层不是拍脑袋定的而是因为三层的失败模式完全不同分开才好排查。1.2 技术选型的几个关键取舍向量库这块常见选择有Milvus、Qdrant、Weaviate、pgvector。我最终倾向Qdrant做主力理由是它的过滤向量混合查询在工程上比较顺手payload里可以直接塞实体ID方便和图库对齐。如果团队已经有PostgreSQL运维经验pgvector是更省事的选择代价是千万级向量以上性能会吃紧。图库这块Neo4j生态最成熟Cypher查询语言上手快APOC插件做路径分析很省心。如果数据量在十亿边以上可以考虑NebulaGraph这类分布式方案但中小规模项目没必要提前上分布式运维成本会反噬开发效率。大模型侧我建议检索和生成分开用不同模型意图解析用响应快的小模型7B级别足够最终答案生成用能力更强的大模型。这样既控成本又保证回答质量。提示选型阶段最忌讳“一步到位上最强”。我见过团队直接上分布式图库结果数据量才几十万节点运维复杂度却拖垮了整个迭代节奏。1.3 数据流的整体走向一条完整的请求链路是这样的用户提问 → 大模型解析出实体和关系意图 → 向量库做语义召回拿到候选文档 → 从候选文档里抽取实体ID → 图库做多跳关联查询 → 两路结果合并重排 → 大模型生成带引用来源的回答。这里有个容易被忽略的点向量召回的结果要反哺图查询。也就是说图查询的起点实体不是从用户原话里硬抽的而是从向量召回的文档里提取的。这样做的好处是容错——用户说“那个做电池的龙头”向量能召回对应文档实体抽取自然就准了。2. 核心细节解析与实操要点2.1 向量库的Schema设计要点向量库的collection设计直接决定后续能不能和图库对齐。我的做法是每条向量记录都带这几个payload字段entity_ids该文档涉及的实体ID列表、source来源标识、chunk_index分块序号、timestamp。分块策略上我实测按语义段落切比固定长度切效果好。固定512 token切分经常把一句话拦腰截断导致向量语义偏移。用递归字符切分配合段落边界召回准确率能提升一截。向量维度方面如果用的是常见的中文embedding模型768或1024维都够用。别盲目追高维维度翻倍存储和检索成本是线性增长的而召回提升往往不明显。# Qdrant collection 创建示例 from qdrant_client import QdrantClient from qdrant_client.models import Distance, VectorParams client QdrantClient(hostlocalhost, port6333) client.create_collection( collection_nameknowledge_chunks, vectors_configVectorParams(size1024, distanceDistance.COSINE), )2.2 图库的节点与边建模图建模的核心是实体归一化。同一个公司在不同文档里可能叫“某某科技”“某某科技有限公司”“某科技”如果不做归一化图里会出现一堆孤立节点关联推理直接失效。我的做法是建一个Entity标签用canonical_name做唯一约束别名放在aliases数组里。边的话除了显式关系投资、任职、合作还要建一类MENTIONED_IN边指向文档节点这样从实体能反查到原始文档回答时能给出引用来源。// 实体唯一约束 CREATE CONSTRAINT entity_name IF NOT EXISTS FOR (e:Entity) REQUIRE e.canonical_name IS UNIQUE; // 建立实体与文档的提及关系 MERGE (e:Entity {canonical_name: $name}) MERGE (d:Document {doc_id: $doc_id}) MERGE (e)-[:MENTIONED_IN]-(d);2.3 大模型意图解析的Prompt设计意图解析这一步Prompt要输出结构化JSON包含entities实体列表、relation_hint关系意图、hop_count预期跳数。hop_count这个字段很关键——用户问“A的创始人是谁”是1跳问“A的创始人还投资了谁”是2跳提前判断跳数能避免图查询无限遍历。我踩过的坑是小模型有时候会把hop_count猜错。解决办法是给几个few-shot示例并且在图查询时设一个最大跳数上限比如3跳超了就返回“关系过远建议细化问题”。注意意图解析的Prompt里一定要明确“如果无法确定实体返回空列表”否则模型会硬编一个实体出来导致后续查询全错。2.4 双通道结果的融合重排向量召回给的是语义相似度分数图查询给的是路径长度和关系权重这两个分数不在一个量纲上直接相加没意义。我的做法是先归一化再加权向量分数做min-max归一化图路径分数用1/(1hop)转换然后按0.6:0.4加权。重排之后还要做去重——同一篇文档可能既被向量召回又被图路径带出来这时候保留分数高的那条并在元数据里标记“双通道命中”这类结果通常质量最高可以在回答里优先展示。3. 实操过程与核心环节实现3.1 环境搭建与依赖安装先起基础设施。Qdrant和Neo4j都可以用容器跑本地开发足够。生产环境再考虑集群。# 启动 Qdrant docker run -d --name qdrant -p 6333:6333 -p 6334:6334 \ -v $(pwd)/qdrant_storage:/qdrant/storage qdrant/qdrant # 启动 Neo4j docker run -d --name neo4j -p 7474:7474 -p 7687:7687 \ -e NEO4J_AUTHneo4j/password123 \ -v $(pwd)/neo4j_data:/data neo4j:5Python侧需要qdrant-client、neo4j、sentence-transformers或调用云端embedding API、以及大模型的SDK。建议用虚拟环境隔离依赖版本锁死embedding模型版本变更会导致向量空间漂移必须记录版本号。3.2 数据入库从原始文档到双库落地入库流程分四步文档解析 → 分块 → 向量化入Qdrant → 实体抽取入Neo4j。实体抽取我用大模型做Prompt要求输出{entities: [{name, type, aliases}], relations: [{head, relation, tail}]}。抽取完先做一轮归一化——把别名映射到canonical_name再写入图库。这里有个实操细节入库要幂等。同一篇文档重复入库不能产生重复节点。Qdrant侧用doc_idchunk_index做去重键Neo4j侧用MERGE语句天然幂等。# 实体归一化与入库 def upsert_entity(tx, name, aliases, doc_id): tx.run( MERGE (e:Entity {canonical_name: $name}) SET e.aliases $aliases WITH e MERGE (d:Document {doc_id: $doc_id}) MERGE (e)-[:MENTIONED_IN]-(d) , namename, aliasesaliases, doc_iddoc_id)3.3 检索链路一次完整查询的拆解假设用户问“某新能源公司的创始团队还布局了哪些上下游企业”。第一步意图解析输出entities[某新能源公司]relation_hint创始团队投资上下游hop_count2。第二步向量召回。用原问题做embedding在Qdrant里检索top-20拿到候选文档和其中的实体ID。第三步图查询。以候选实体为起点做2跳遍历找INVEST和SUPPLY类型的边。MATCH (e:Entity {canonical_name: $name})-[:FOUNDED_BY]-(p:Person) -[:INVEST]-(c:Company)-[:SUPPLY]-(up:Company) RETURN p.name, c.name, up.name LIMIT 20;第四步融合重排。把图查询结果和向量召回结果按前述权重合并取top-5送入生成模型。第五步生成回答。Prompt里要求模型“只基于提供的上下文回答每个结论标注来源”避免幻觉。3.4 参数调优的实测记录向量召回top-k我试过10、20、50三档。k10时召回不全k50时噪声太多拖慢重排。k20是性价比拐点再大收益递减。图查询的最大跳数我设成3。实测超过3跳的路径关系已经非常弱强行返回反而干扰回答。可以在返回结果里标注跳数让生成模型自己判断可信度。融合权重0.6:0.4不是拍脑袋的。我做过一轮小规模评测向量权重从0.5到0.7扫了一遍0.6时综合准确率最高。但这个值跟数据分布强相关建议在自己的数据集上重新扫一遍。参数取值调整依据向量top-k20召回率与噪声的平衡点最大跳数3超过3跳关系可信度骤降融合权重0.6:0.4本地评测最优分块大小语义段落固定长度会截断语义4. 常见问题与排查技巧实录4.1 向量召回准但图查询空结果这是最常见的组合故障。原因通常是实体归一化没做好——向量召回的文档里实体叫“某科技”图库里存的是“某科技有限公司”对不上。排查方法把向量召回文档里的实体名和图库里的canonical_name做一次模糊匹配看差异在哪。解决手段是建一个别名映射表入库和查询时都过一遍映射。提示别名映射表要持续维护新文档入库时如果发现未映射的别名应该告警而不是静默跳过。4.2 图查询超时或结果爆炸多跳查询不加限制很容易在稠密图上炸开。我遇到过一次2跳查询返回上万条路径直接把服务拖垮。解决办法有三层一是查询语句里强制LIMIT二是设置查询超时Neo4j支持dbms.transaction.timeout三是在应用层做路径剪枝按关系权重排序后只取前N条。4.3 生成模型忽略上下文自己编这是幻觉问题。根因通常是Prompt里没强调“只用上下文”或者上下文太长模型注意力涣散。我的做法是上下文控制在2000 token以内每条上下文带编号Prompt里明确“引用时标注编号无编号内容不得出现在回答中”。另外可以在生成后加一道校验——检查回答里的实体是否都在上下文里出现过没出现的标记为可疑。4.4 常见问题速查表现象可能原因排查方向图查询空结果实体未归一化对比别名与canonical_name查询超时跳数过多/无LIMIT加跳数上限和LIMIT回答有幻觉Prompt约束不足加强引用要求后校验召回不准分块截断语义改语义段落切分双库数据不一致入库非幂等检查去重键与MERGE4.5 几个踩坑后的经验第一别在检索层做太多逻辑。我一开始把重排规则写得很复杂后来发现规则越多越难维护不如把原始分数都传给生成模型让它自己权衡。第二日志要记全链路。每次查询把意图解析结果、向量召回ID、图路径、融合分数都记下来出问题时能快速定位是哪一层的问题。我吃过没记日志的亏一个召回问题查了两天。第三评测集要早建。没有评测集参数调优就是盲调。我建议项目初期就攒50-100条问答对覆盖单跳、多跳、模糊指代几类场景每次改动跑一遍。这个项目后续还可以往两个方向扩展一是把时序信息加进图模型支持“某时间点之后的关系变化”这类查询二是引入重排序模型替代当前的加权融合效果通常能再上一个台阶。不过那是另一个话题了先把双通道跑通、跑稳比什么都重要。