一个独立完成的生产级 Graph RAG 应用。后端约 9,000 行 Python、前端约 4,400 行 Vue3,知识图谱 5,760 个节点 / 6,055 条关系 / 323 道菜谱。本文记录它从"切块→向量化→top-k"的朴素 RAG,逐步演进到"知识图谱 + 三路混合检索 + 图多跳推理 + LLM 智能路由"的完整设计与工程权衡。

一、为什么不满足于朴素 RAG
朴素 RAG 的范式很简单:文档切块 → embedding → 向量相似度 top-k → 塞进 prompt 喂给 LLM。它在"找一段长得像的文本"这类任务上足够好用,但落到菜谱领域就暴露了三个结构性问题:
- 回答不了关系型问题。"鸡肉配什么蔬菜""同时用到鸡蛋和番茄的菜有哪些"------这些本质是图遍历和集合交集,向量检索只能靠语义模糊匹配,召回的往往是"听起来相关"的文本块,而不是精确的关系。
- **切块打碎了结构化知识。**一道菜天然是"菜---食材---步骤---分类"的关系网络,按 500 字滑窗切块后,一次命中可能只捞到"标签"或"某一步骤",LLM 拿着碎片只能编。
- **单一召回通道有盲区。**关键词匹配擅长精确词("宫保鸡丁"),向量擅长同义/近义("怎么做"≈"做法"),但没有任何单路能同时覆盖精确匹配、语义泛化和关系推理。
于是这个项目没有停在 baseline 上,而是做了三层升级:
| 层次 | 朴素 RAG | 本项目 |
|---|---|---|
| 数据源 | 纯文本块 | Neo4j 知识图谱(结构化关系)+ 文本 |
| 检索 | 单路向量 | 三路异构召回 + RRF 融合 + Cross-encoder 精排 |
| 推理 | 关键词匹配 | 图多跳遍历 + LLM 查询规划 + 智能路由 |
最终系统能回答多跳/交集类关系查询,同时保留传统检索在简单事实查询上"又快又准"的优势------而要哪一种,由一个 LLM 路由器自动决定。 
二、整体架构
系统前后端分离,后端是编排核心,前端负责流式展示与检索轨迹可视化。
scss
用户 query
→ 智能路由(LLM 四维分析)选策略
├─ 传统混合检索:
│ LLM 提取双层关键词(实体级/主题级)
│ → 图 KV 双层 + Milvus 向量 + BM25 三路并行召回
│ → RRF(k=60) 融合出 20 候选
│ → bge-reranker cross-encoder 精排 → top_k(5)
│ → 父文档回填前 5 条(完整菜谱替换碎片 chunk)
├─ 图 RAG:
│ LLM 出图查询计划(6 种类型)
│ → Cypher 多跳遍历 / 子图 / 交集查询 + 路径评分
│ → 空结果自动 fallback 混合检索
└─ 组合:传统满额 + 图预算 2,Round-robin 交替去重
→ 组装上下文 → LLM 流式生成(重试 + 降级非流式)
→ SSE 逐 token 返回 + 检索轨迹/来源/图谱可视化
技术栈:
- 后端:Python / FastAPI / Neo4j / Milvus(Milvus Lite)/ DeepSeek(OpenAI 兼容接口)/ BGE(embedding + reranker)/ jieba + rank_bm25 / RRF / RAGAS / LangChain
- 前端:Vue3 + TypeScript + Vite + Element Plus + Pinia + SSE(手写 fetch + ReadableStream)+ vis-network 图谱渲染
- 规模:图谱 5,760 节点 / 6,055 关系(其中 REQUIRES 2,905、CONTAINS_STEP 2,514),323 个 Markdown 菜谱,10 个分类;Embedding 用 BGE-small-zh-v1.5,512 维。
后端模块的职责切分很清晰:
arduino
backend/
├── app.py FastAPI 入口(lifespan 后台异步初始化)
├── main.py AdvancedGraphRAGSystem 编排层
├── api/ 路由 / Schema / 会话 / 评估存储
├── config.py 全部超参(dataclass)
└── rag_modules/
├── graph_data_preparation.py 图数据加载、文档拼装、智能分块
├── milvus_index_construction.py 向量索引(HNSW)
├── markdown_parser.py 上传菜谱解析
├── graph_indexing.py 图 KV 双层索引(LightRAG 风格)
├── hybrid_retrieval.py 三路召回 + RRF + 父文档回填
├── reranker.py cross-encoder 精排
├── graph_rag_retrieval.py 图多跳推理
├── intelligent_query_router.py LLM 智能路由 + 组合策略
├── generation_integration.py 流式答案生成
└── evaluation.py RAGAS 评估
三、离线建库:把图谱变成可检索的三种索引
3.1 从图节点到自然语言 Document
Neo4j 里存的是结构化的 Recipe / Ingredient / CookingStep 节点和 REQUIRES / CONTAINS_STEP / BELONGS_TO_CATEGORY 关系。建库时先把每个菜谱 JOIN 它的食材、步骤、分类,拼成一篇带 ## 标题的自然语言文档(见 graph_data_preparation.build_recipe_documents):
shell
# 西红柿炒鸡蛋
## 菜品描述 ...
## 所需食材
1. 西红柿(2个) 2. 鸡蛋(3个) ...
## 制作步骤
### 第1步
步骤: 西红柿切块,鸡蛋打散...
## 标签
下饭菜、快手菜
这一步很关键:向量和 BM25 检索的对象是"可读的菜谱文章",而不是干瘪的三元组,保证了召回内容喂给 LLM 时是通顺的。
3.2 智能分块:优先保结构,长度兜底
分块策略不是无脑滑窗,而是优先按 ## 二级标题切分以保留语义完整性;只有当文档没有二级标题时,才退化为 500 字、overlap 50 的滑动窗口(chunk_documents):
python
sections = content.split('\## ')
if len(sections) <= 1:
# 无标题:固定长度滑窗
total_chunks = (len(content) - 1) // (chunk_size - chunk_overlap) + 1
...
else:
# 按二级标题分块,每段以 ## 开头保留章节结构
chunk_content = f"## {section}"
每个 chunk 记录 chunk_id、parent_id(菜谱 nodeId)、chunk_index,为后面的"父文档回填"埋下伏笔。
3.3 三套异构索引
同一份 chunks 同时进入三套索引,互为补充:
- Milvus 向量索引 :BGE-small-zh 编码成 512 维归一化向量,建 HNSW 索引 (
M=16、efConstruction=200、查询ef=64、COSINE)。归一化后余弦等价于内积,比较高效。 - BM25 索引 :jieba 精确分词 + 手工中文停用词表(助词/连词/疑问词/代词等),用
rank_bm25.BM25Okapi建索引,擅长精确关键词。 - 图 KV 双层索引 :借鉴 LightRAG 的
(K,V)思想,把图实体和关系拍平成内存哈希表,并建反向索引支持 O(1) 按关键词反查(下一节详述)。
四、三路混合检索与 RRF 融合
4.1 图 KV 双层检索:实体级 + 主题级
混合检索的第一路是图键值索引。GraphIndexingModule 维护两个存储:
entity_kv_store:nodeId → EntityKeyValue,把菜名/食材/步骤的属性拼成value_content,index_keys至少包含实体名;relation_kv_store:relation_id → RelationKeyValue,并按关系类型预生成主题关键词做反向索引。
主题键的生成是这一路的点睛之笔(_generate_relation_index_keys):
python
if relation_type == "REQUIRES":
keys.extend(["食材搭配", "烹饪原料", f"{source_entity.entity_name}_食材", target_entity.entity_name])
elif relation_type == "HAS_STEP":
keys.extend(["制作步骤", "烹饪过程", f"{source_entity.entity_name}_步骤", "制作方法"])
elif relation_type == "BELONGS_TO_CATEGORY":
keys.extend(["菜品分类", "美食类别", target_entity.entity_name])
这样"鸡肉配什么"里的"配"能通过"食材搭配"命中 REQUIRES 关系,"川菜有什么"能通过"菜品分类"命中 BELONGS_TO_CATEGORY------主题级检索无需遍历整张图,查哈希表即可。
检索前先用 LLM 把 query 拆成两层关键词(extract_query_keywords,temperature=0.1 保证稳定):
- 实体级:具体食材/菜名/工具(鸡胸肉、平底锅);
- 主题级:抽象概念/风格(减肥、川菜、下饭菜)。
两层分别检索并赋予不同规则分:实体精确匹配 0.9、关系级主题匹配 0.95、分类匹配 0.85、Neo4j 兜底 0.75。每路命中还会查一跳邻居拼到内容里做上下文增强。如果内存 KV 没命中够,会自动降级到 Neo4j fulltext 索引、再降级到 Cypher CONTAINS 模糊匹配。
4.2 三路并行召回
hybrid_search 一次发起三路召回,每路都给 candidate_k = max(rrf_k*2, 10) 个候选(确保融合时有足够交集):
python
dual_docs = self.dual_level_retrieval(query, candidate_k) # 图 KV 双层
vector_docs = self.vector_search_enhanced(query, candidate_k) # Milvus + 一跳邻居
bm25_docs = self.bm25_search(query, candidate_k) # BM25
向量检索也做了图增强:每个命中的 Recipe 节点会去 Neo4j 查一跳邻居(食材/步骤),拼到原文末尾。
4.3 为什么必须用 RRF 而不是加权分数
三路分数完全不在一个量纲:BM25 是无界的 TF-IDF 分、向量是 0--1 余弦、图 KV 是手设的 0.75--0.95 规则分。硬加权没有物理意义,权重调参是无底洞。
项目采用 Reciprocal Rank Fusion(Cormack et al. 2009),只用排名、不看原始分:
score(d)=∑ik+best_ranki(d)1,k=60
实现在 _rrf_merge,工程细节做得相当扎实:
- 同 source 去重算分 :一道菜的多个 chunk 共享
node_id,同一通道里只取最佳(最小)rank 算一次分,避免重复加分;命中 chunk 数另存为rrf_chunk_hits。 - 去重 key 兜底 :优先
node_id,缺失时回退page_content[:200]的 MD5。 - canonical doc 选择:最终展示给 LLM 的内容取全局最小 rank 对应的 chunk;rank 相同按通道优先级。
- 保留每路原始分 :
rrf_raw_scores记下各通道原始分供前端分数对比,但不参与算分。 - 浅拷贝返回:新建 Document,不 mutate 上游对象。
python
rrf_scores = {
doc_id: sum(1.0 / (k + r) for r in source_ranks.values())
for doc_id, source_ranks in best_rank_per_source.items()
}
RRF 先融出 rerank_candidate_k=20 个候选(开启重排时),而不是直接取 top_k------这是为下一阶段精排准备足够大的候选池。
五、两阶段检索:Cross-encoder 精排
召回阶段用的是 bi-encoder(query 和 doc 各自编码算余弦),速度快但细粒度弱;精排阶段用 cross-encoder 把 (query, doc) 拼在一起过模型,能捕捉 token 级交互,相关性判断准得多。
标准的 two-stage retrieval 就是"粗筛多候选 → cross-encoder 精排 → 只留 top_k"。项目用 BAAI/bge-reranker-v2-m3:
python
pairs = [(query, d.page_content) for d in documents]
scores = self._model.predict(pairs, batch_size=16, show_progress_bar=False)
ranked = sorted(zip(documents, scores), key=lambda x: float(x[1]), reverse=True)
rerank_score = self._sigmoid(raw_score) # logit → (0,1),保持单调性
设计上贯彻了"懒加载 + 可降级":
- 模型首次
rerank()才加载,启动零成本;_ensure_model用双重检查锁防止并发首查重复加载; - 模型未缓存(离线环境)/加载失败/打分异常时,优雅降级为 RRF 原序前 top_k,不影响回答;
- 精排后把
final_score设为rerank_score,让"得分"与排序一致,同时保留rrf_score供对比面板。
六、父文档回填:别让 LLM 拿着碎片编答案
chunk 切碎后,排前面的结果可能只是"## 标签"或"### 第3步"这类局部内容。LLM 拿到不完整的碎片,很容易靠想象补全------这正是幻觉的来源之一。
项目在 rerank 之后 做父文档回填(_attach_parent_documents):对前 parent_doc_top_n=5 条命中,用初始化时懒建的 {node_id: 完整菜谱 Document} 映射,把 chunk 的 page_content 替换成完整菜谱(食材+步骤齐全,超 4000 字截断),排名和数量不变:
python
for i, doc in enumerate(docs):
if i >= top_n: # 只回填前 N 条,其余原样传递
out.append(doc); continue
parent = pmap.get(str(nid))
if parent is None: # 新菜谱找不到父文档,保持原样
out.append(doc); continue
out.append(Document(page_content=pc, metadata=dict(doc.metadata)))
这是个可配置开关(enable_parent_doc_retrieval),效果上让喂给 LLM 的头部上下文从"一句话碎片"变成"完整菜谱"。
七、图 RAG:真正的多跳关系推理
混合检索再强,本质还是"匹配"。要回答关系型问题,得让图自己"走"起来。GraphRAGRetrieval 是这套系统区别于普通 RAG 的核心。 
7.1 LLM 把自然语言翻译成图查询计划
入口 understand_graph_query 让 LLM 把问题映射成一个结构化的 GraphQuery,包含 6 种查询类型:
| 类型 | 典型问题 | 图操作 |
|---|---|---|
ENTITY_RELATION |
鸡肉和胡萝卜能一起做吗 | 一跳关系 |
MULTI_HOP |
鸡肉配什么蔬菜 | 变长路径遍历 |
SUBGRAPH |
川菜有什么特色 | 围绕核心实体的局部子图 |
PATH_FINDING |
从食材到成品菜的路径 | 最短路径(占位) |
CLUSTERING |
和宫保鸡丁类似的菜 | 子图/聚类 |
INTERSECTION |
同时用到鸡蛋和番茄的菜 | 多实体交集 |
prompt 里把图 schema(节点类型、关系类型、属性)直接告诉 LLM,并强调"只放图里大概率有对应节点的具体实体,把糖尿病/30分钟这类约束放进 constraints",还给了 3 个带 JSON 的 few-shot。LLM 失败时降级为默认 subgraph 查询。
7.2 多跳遍历与路径评分
MULTI_HOP 用 Cypher 变长路径语法 (source)-[*1..N]-(target),并在查询里直接算相关性:
cypher
MATCH path = (source)-[*1..{max_depth}]-(target)
WHERE NOT source = target
WITH path, ..., length(path) as path_len, relationships(path) as rels, nodes(path) as path_nodes
WITH ...,
(1.0 / path_len) + -- 短路径得分高
(REDUCE(s=0.0, n IN path_nodes | s + COUNT{ (n)--() }) / 10.0 / size(path_nodes)) + -- 高度数节点加分
(CASE WHEN ANY(r IN rels WHERE type(r) IN $relation_types) THEN 0.3 ELSE 0.0 END) as relevance
ORDER BY relevance DESC LIMIT 20
评分综合了三个信号:路径越短越权威、途经高度数(枢纽)节点越好、关系类型命中 LLM 指定的偏好类型加分 。每条路径随后被渲染成 宫保鸡丁 --REQUIRES--> 鸡胸肉 --REQUIRES--> ... 的自然语言描述喂给 LLM,同时保留结构化的节点/关系链供前端画推理路径。
7.3 子图提取与图谱密度
SUBGRAPH 用原生 Cypher(不依赖 APOC 插件)抓核心实体 N 跳内的邻居,并计算图谱指标:
cypher
density: CASE WHEN node_count > 1
THEN toFloat(rel_count) / (node_count * (node_count - 1) / 2) -- 边数/最大可能边数
ELSE 0.0 END
这里有个很实用的工程防御:空壳子图绝不生成 Document。代码里拦了三种"空"------无中心也无连通节点、中心全是无名节点、有中心但孤立无边,避免把"关于 的知识网络,包含 0 个概念和 0 个关系"这种垃圾文本喂给 LLM、占用 top_k 名额。这个过滤在后续组合策略里至关重要。
7.4 交集查询:同义词扩展 + 加工品过滤
"同时用到鸡蛋和番茄的菜"是最能体现图数据库优势的查询,但实现里有两个中文领域的暗坑。
第一,同义词。 用户说"番茄",图里可能写的是"西红柿"。_expand_synonyms 维护了一张常见食材同义词表(番茄↔西红柿↔圣女果、土豆↔马铃薯↔洋芋、鸡蛋↔蛋黄↔蛋白......),查询时把每个实体扩成同义词组。
第二,加工品误匹配。 如果只写 i.name CONTAINS '番茄',"番茄酱""番茄汁"都会被当成"番茄"匹配进来,答非所问。Cypher 里用后缀黑名单排除加工品:
cypher
WHERE (i.name = syn
OR (i.name CONTAINS syn
AND NOT ANY(suffix IN ['酱','汁','粉','油','膏','露'] WHERE i.name ENDS WITH suffix)))
SQL 层拿到候选后,Python 层再做二次打分:必须每个查询实体都有精确匹配(原词或核心同义词),半成品匹配只给 0.3 权重且会被整道菜过滤掉;菜名里含查询词再额外加 0.2(如"西红柿炒鸡蛋")。这套逻辑保证了"找同时用到鸡蛋和番茄的菜"不会被"鸡蛋+番茄酱"污染。
交集本身用"按 Recipe 分组,统计匹配到几个不同实体组 collect(DISTINCT group.entity_idx),要求组数 ≥ 实体数"实现,是个很经典的 Cypher 集合交集写法。
7.5 图索引预热
图查询频繁按度数取枢纽节点,所以初始化时把按度数排序的 Top 1000 实体 和全部关系类型频次预加载到内存缓存(_build_graph_index),减少在线 DB 访问:
cypher
MATCH (n) WITH n, COUNT { (n)--() } as degree
RETURN labels(n), n.nodeId, n.name, n.category, degree
ORDER BY degree DESC LIMIT 1000
八、LLM 智能路由与组合策略
不是每个问题都需要图推理------"红烧肉怎么做"用传统检索又快又好,强行走图反而可能抽不到实体。于是加了一层路由器。
8.1 四维分析选策略
IntelligentQueryRouter.analyze_query 让 LLM 从四个维度给 query 打分(temperature=0,刻意保证同问同策略,避免路由抖动让评估和在线结果不一致):
query_complexity(简单信息查找 0--0.3 → 中等 → 高复杂度推理 0.8+)relationship_intensity(单一实体 → 实体间关系 → 复杂关系网络)reasoning_required(是否需要多跳/因果/对比)entity_count
输出三选一:hybrid_traditional / graph_rag / combined。prompt 里特别强调:"只要涉及多个实体关联(同时用到/一起/都/既...又...),哪怕看起来是找菜,也应推荐 graph_rag"。
LLM 挂了有规则兜底(_rule_based_analysis):用复杂度/关系关键词命中、正则识别交集模式(同时.*用、既.*又.*),置信度 0.6。
8.2 组合策略:一次有明确权衡的重构
combined 策略的设计经历过一次刻意重构,值得专门讲。
最早的版本 是把 top_k 名额平分给两路(传统 //2、图 //2)。上线后发现问题:图 RAG 返回的是"多跳路径/子图"这类推理线索,不是答案主体证据;平分会让质量参差的子图挤掉高质量的完整菜谱。
重构后的 v2 策略 (_combined_search):
- 传统路给完整 top_k 名额,让 reranker 从 20 候选池自由挑最优 top_k 个完整菜谱;
- 图 RAG 只给一个小预算
combined_graph_budget=2,明确它只提供辅助线索; - 用 Round-robin 交替 合并,且图优先(把推理线索前置,帮 LLM 先建立关系认知);
- 用
page_content[:100]的 MD5 内容哈希去重,最终截断到 top_k。
python
for i in range(max_len):
if i < len(graph_docs): # 图优先
add_if_new(graph_docs[i], "graph_rag")
if i < len(traditional_docs):
add_if_new(traditional_docs[i], "traditional")
return combined_docs[:top_k]
这是个典型的工程权衡:承认图结果"是线索不是答案",用预算和顺序而不是平分来表达这个判断。
8.3 图空结果的兜底
路由器里还有一道关键保险:图 RAG 抽不到实体或子图为空时会返回 [],此时自动 fallback 到传统混合检索,避免"策略选了图但结果空"的极端情况------这条路径在评估时尤其容易触发。
九、答案生成:流式、重试与降级
生成模块(GenerationIntegrationModule)把检索文档和问题组装成统一提示词(流式/非流式共用一份模板,避免漂移),调 DeepSeek 流式接口。
健壮性是这个模块的重点:
- 流式带 3 次重试,递增等待 2s/4s/6s,应对 429/超时/网络抖动;
- 流式彻底失败 → 降级非流式;非流式也有 60s timeout + 重试;
- SSE 在线程池里逐 token
next(),专门用哨兵_STREAM_END处理StopIteration------因为 generator 的StopIteration进 asyncio Future 会"interacts badly with generators",必须在线程内捕获转成哨兵。
十、全链路优雅降级
"任何一个组件挂了都不能让聊天白屏"是贯穿全栈的设计原则。降级链条几乎出现在每一层:
| 环节 | 降级策略 |
|---|---|
| LLM 路由分析 | 规则关键词兜底(置信度 0.6) |
| 图 KV 未命中 | Neo4j fulltext → Cypher CONTAINS 两级降级 |
| 图 RAG 空结果 | 自动 fallback 传统混合检索 |
| 子图/路径为空 | 不生成空壳 Document,直接跳过 |
| Reranker 模型缺失 | 跳过重排,返回 RRF 原序 |
| 流式生成 | 重试 3 次 → 降级非流式 |
| RAGAS 未安装 | 评估接口返 503 + 安装提示,主应用照常运行 |
| BM25 分词缓存损坏 | 丢弃缓存重新分词 |
| 后端未就绪 | /api/health 返 ready=false,业务接口返 503,前端轮询 |
这套设计让系统在模型没下全、网络抖动、图数据不全时都能"带伤运转",而不是整体不可用。
十一、RAGAS 评估闭环:用数据驱动迭代
如果不能量化,"效果变好了"就只是感觉。项目集成了 RAGAS 四项指标,把优化变成可测量的闭环。
11.1 四项指标
- 忠实度 Faithfulness :答案的每个论断能否由检索上下文支持(无幻觉),不需要参考答案;
- 答案相关性 Answer Relevancy:让 LLM 从答案反推问题,与原问题比相似度(不需参考答案,但需 embedding);
- 上下文召回 Context Recall:参考答案的信息是否都被检索上下文覆盖(需 ground truth);
- 上下文精确 Context Precision:相关项是否排在前面(MRR 风格,需 ground truth)。
内置 8 条烹饪测试集(data/eval_dataset.json,带参考答案),覆盖食材、图 RAG 关系、分类列举、做法等类别。评估复用现有的 DeepSeek 作 judge、BGE 作 embedding。
11.2 工程细节
- 懒加载:ragas / judge LLM / embeddings 首次评估才初始化,启动零成本;
- 防御式导入 :ragas 0.2 指标名优先、0.1 别名回退,适配版本差异;指标列名读
metric.name自适应(0.4.x 里ResponseRelevancy实际输出列是answer_relevancy); - 稳定性调优 :judge
temperature=0、max_retries=6,max_workers默认 2(单样本常触发 10+ 次 judge 调用,并发 3+ 在长答案上易 429 导致整批 NaN),raise_exceptions=False让单样本异常返 NaN 不中断整批; - 两个入口:测试集批量评估(跑完整 RAG 管线)+ 在线单条评估(可从会话历史选一条,重新检索完整上下文评估),历史结果落盘可对比。
11.3 数据驱动的效果跃迁
evaluation_results.json 里留存了 12 次评估记录,清晰地记录了迭代轨迹:
| 阶段 | 忠实度 | 上下文召回 | 上下文精确 |
|---|---|---|---|
| 起点 eval-1(朴素三路初版) | 0.214 | 0.208 | --- |
| 最佳 eval-11(+reranker/父文档/同义词等) | 0.721 | 0.696 | 0.763 |
忠实度从 0.21 提到 0.72、召回从 0.21 提到 0.70,主要由这几项迭代驱动:引入 cross-encoder 精排提升头部相关性、父文档回填给 LLM 完整上下文减少编造、同义词扩展和加工品过滤修复交集查询、图空结果 fallback 避免空上下文。
也有诚实的不足:answer_relevancy 因 judge 模型限流一直是 null,评估分数在不同 run 间有波动(eval-5 曾跌到 0.144),说明对 judge LLM 的鲁棒性还不够------这些都在文档里明确记录,而不是假装完美。
十二、工程化
12.1 FastAPI + SSE,后台初始化不阻塞
app.py 用 lifespan 在后台 asyncio.to_thread 初始化 RAG 系统(连 Neo4j/Milvus、加载 embedding、建索引都是阻塞操作),HTTP 服务立即启动;/api/health 反映就绪状态,未就绪业务接口返 503。所有阻塞的检索/生成/上传在线程池执行,不卡事件循环。
SSE 用 sse_starlette.EventSourceResponse,事件分三类:先推一个 analysis 事件(路由分析 + 来源 + 检索轨迹),再逐 chunk 推 token,最后 done/error。前端因为要 POST(EventSource 不支持 POST),手写了 fetch + ReadableStream 客户端。
12.2 缓存与性能
- BM25 分词指纹缓存 :jieba 分词是 CPU 密集操作,启动慢主要慢在这。把
tokenized_corpuspickle 到.bm25_cache/,用"chunk 数 + 每个 chunk_id 和内容的 MD5"算语料指纹,内容/数量/顺序任一变化缓存自动失效,命中则跳过分词直接算 IDF。 - 文档解析缓存:菜谱文档同样有 MD5 指纹缓存。
- UNWIND 消除 N+1 :图查询用
UNWIND $keywords/UNWIND $entity_groups批量传参,避免在 Python 里循环发查询。 - HNSW 参数:M=16、efConstruction=200、查询 ef=64,在召回和延迟间取平衡。
12.3 会话与并发安全
聊天会话落盘 JSON,用原子写(写临时文件再 rename)+ 线程锁防止并发写入损坏。
12.4 前端:把检索过程画出来
前端不只是个聊天框,还把"为什么推荐这个结果"可视化:
- 检索轨迹面板:展示三路各自的候选数、最终入选数、是否走了重排、候选池大小;
- 分析标签:显示路由策略、复杂度、关系密集度、置信度;
- 图 RAG 推理路径 :把
path_nodes/path_relationships渲染成节点链,标出走了几跳、哪些关系;子图展示密度、节点数; - 知识图谱可视化:单菜谱 1-hop 子图用 vis-network 画成可交互网络图。
十三、数据闭环:9 步增量上传菜谱
系统支持用户上传 Markdown 菜谱,upload_markdown_recipe 串起一条 9 步增量入库流水线(markdown_parser 负责解析,支持"必备原料/计算/操作/附加内容"章节、用量行的多种格式、★ 难度计数):
- 解析 Markdown → 结构化
ParsedRecipe; - 事务写入 Neo4j(Recipe/Ingredient/CookingStep + 关系),更新内存节点列表;
- 清理 Milvus 中该菜谱旧向量(覆盖上传场景);
- 拼装成与离线一致的 LangChain Document;
- 复用同一套分块逻辑分块(按
##标题,兜底滑窗); - 增量插入 Milvus;
- 重建 BM25 索引(
BM25Okapi不支持增量加文档,借指纹缓存跳过分词); - 增量更新图 KV 索引(加实体/关系/反向索引,已存在的食材复用);
- 更新父文档映射 + 清文档缓存。
入库时同样做了同义词和加工品处理,保证上传路径和历史路径的检索一致性。
十四、不足与下一步
文档里没有回避问题,反而把"已知欠账"列得很清楚:
- 关系类型不一致 bug :图 KV 索引用
HAS_STEP,数据层和图 RAG 用CONTAINS_STEP,导致步骤关系的主题键可能匹配不上实际边------新上传路径已统一,历史路径待修。 - 配置工程化欠账 :
config.py是 dataclass 硬编码默认值,环境变量读取不全,.env.example的 key 名与代码有漂移,是从"能跑"推向"可部署"的下一步。 - 占位实现 :图推理链
_build_reasoning_chain、最短路径、实体一跳关系查询目前是占位。 - 评估鲁棒性:answer_relevancy 因限流长期 null,分数随 judge 波动;下一步可换更强/更稳的 judge、缓存查询分析结果、降 judge 并发。
- 扩展性:图 KV 索引目前在内存,数据量涨 100 倍需改外部索引/分片;BM25 可换 Elasticsearch;Neo4j 多跳查询要限深度加索引;高频查询加缓存。
- 延迟:一次查询最多 4 次 LLM 调用(关键词提取、查询分析、图意图理解、答案生成),中间步骤可用小模型替代或缓存结果。
十五、结语
这个项目最有价值的部分,不是堆了多少新技术,而是一系列有依据的权衡:
- 用 RRF 而不是硬加权,因为三路分数量纲不可比;
- 用 cross-encoder 精排,因为 bi-encoder 召回快但细粒度弱;
- 做父文档回填,因为碎片上下文是幻觉的温床;
- 组合策略里图只给 2 个预算,因为它是线索不是答案;
- 路由 temperature 设 0,因为评估不能容忍抖动;
- 全链路降级,因为可用性比"理论最优"更重要。
它借鉴了 LightRAG(KV 索引 + 双层检索 + Round-robin)和微软 GraphRAG(图结构推理)的思想,但最终落在一个具体的中文垂直领域里,对任何想把朴素 RAG 推向生产可用的人来说,这套"结构化数据源 + 多路召回融合 + 可解释路由 + 量化评估闭环"的组合拳,都是一条可复用的路径。