尝尝咸淡:从朴素 RAG 到 Graph RAG——一个烹饪问答系统的三层检索升级之路

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

一、为什么不满足于朴素 RAG

朴素 RAG 的范式很简单:文档切块 → embedding → 向量相似度 top-k → 塞进 prompt 喂给 LLM。它在"找一段长得像的文本"这类任务上足够好用,但落到菜谱领域就暴露了三个结构性问题:

  1. 回答不了关系型问题。"鸡肉配什么蔬菜""同时用到鸡蛋和番茄的菜有哪些"------这些本质是图遍历和集合交集,向量检索只能靠语义模糊匹配,召回的往往是"听起来相关"的文本块,而不是精确的关系。
  2. **切块打碎了结构化知识。**一道菜天然是"菜---食材---步骤---分类"的关系网络,按 500 字滑窗切块后,一次命中可能只捞到"标签"或"某一步骤",LLM 拿着碎片只能编。
  3. **单一召回通道有盲区。**关键词匹配擅长精确词("宫保鸡丁"),向量擅长同义/近义("怎么做"≈"做法"),但没有任何单路能同时覆盖精确匹配、语义泛化和关系推理。

于是这个项目没有停在 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_idparent_id(菜谱 nodeId)、chunk_index,为后面的"父文档回填"埋下伏笔。

3.3 三套异构索引

同一份 chunks 同时进入三套索引,互为补充:

  • Milvus 向量索引 :BGE-small-zh 编码成 512 维归一化向量,建 HNSW 索引M=16efConstruction=200、查询 ef=64COSINE)。归一化后余弦等价于内积,比较高效。
  • BM25 索引 :jieba 精确分词 + 手工中文停用词表(助词/连词/疑问词/代词等),用 rank_bm25.BM25Okapi 建索引,擅长精确关键词。
  • 图 KV 双层索引 :借鉴 LightRAG 的 (K,V) 思想,把图实体和关系拍平成内存哈希表,并建反向索引支持 O(1) 按关键词反查(下一节详述)。

四、三路混合检索与 RRF 融合

4.1 图 KV 双层检索:实体级 + 主题级

混合检索的第一路是图键值索引。GraphIndexingModule 维护两个存储:

  • entity_kv_storenodeId → EntityKeyValue,把菜名/食材/步骤的属性拼成 value_contentindex_keys 至少包含实体名;
  • relation_kv_storerelation_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)=∑i 1 k+best_ranki(d) ,k=60\text{score}(d) = \sum_i \frac{1}{k + \text{best\_rank}_i(d)},\quad k=60 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/healthready=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=0max_retries=6max_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_corpus pickle 到 .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 负责解析,支持"必备原料/计算/操作/附加内容"章节、用量行的多种格式、★ 难度计数):

  1. 解析 Markdown → 结构化 ParsedRecipe
  2. 事务写入 Neo4j(Recipe/Ingredient/CookingStep + 关系),更新内存节点列表;
  3. 清理 Milvus 中该菜谱旧向量(覆盖上传场景);
  4. 拼装成与离线一致的 LangChain Document;
  5. 复用同一套分块逻辑分块(按 ## 标题,兜底滑窗);
  6. 增量插入 Milvus;
  7. 重建 BM25 索引(BM25Okapi 不支持增量加文档,借指纹缓存跳过分词);
  8. 增量更新图 KV 索引(加实体/关系/反向索引,已存在的食材复用);
  9. 更新父文档映射 + 清文档缓存。

入库时同样做了同义词和加工品处理,保证上传路径和历史路径的检索一致性。

十四、不足与下一步

文档里没有回避问题,反而把"已知欠账"列得很清楚:

  1. 关系类型不一致 bug :图 KV 索引用 HAS_STEP,数据层和图 RAG 用 CONTAINS_STEP,导致步骤关系的主题键可能匹配不上实际边------新上传路径已统一,历史路径待修。
  2. 配置工程化欠账config.py 是 dataclass 硬编码默认值,环境变量读取不全,.env.example 的 key 名与代码有漂移,是从"能跑"推向"可部署"的下一步。
  3. 占位实现 :图推理链 _build_reasoning_chain、最短路径、实体一跳关系查询目前是占位。
  4. 评估鲁棒性:answer_relevancy 因限流长期 null,分数随 judge 波动;下一步可换更强/更稳的 judge、缓存查询分析结果、降 judge 并发。
  5. 扩展性:图 KV 索引目前在内存,数据量涨 100 倍需改外部索引/分片;BM25 可换 Elasticsearch;Neo4j 多跳查询要限深度加索引;高频查询加缓存。
  6. 延迟:一次查询最多 4 次 LLM 调用(关键词提取、查询分析、图意图理解、答案生成),中间步骤可用小模型替代或缓存结果。

十五、结语

这个项目最有价值的部分,不是堆了多少新技术,而是一系列有依据的权衡

  • 用 RRF 而不是硬加权,因为三路分数量纲不可比;
  • 用 cross-encoder 精排,因为 bi-encoder 召回快但细粒度弱;
  • 做父文档回填,因为碎片上下文是幻觉的温床;
  • 组合策略里图只给 2 个预算,因为它是线索不是答案;
  • 路由 temperature 设 0,因为评估不能容忍抖动;
  • 全链路降级,因为可用性比"理论最优"更重要。

它借鉴了 LightRAG(KV 索引 + 双层检索 + Round-robin)和微软 GraphRAG(图结构推理)的思想,但最终落在一个具体的中文垂直领域里,对任何想把朴素 RAG 推向生产可用的人来说,这套"结构化数据源 + 多路召回融合 + 可解释路由 + 量化评估闭环"的组合拳,都是一条可复用的路径。

相关推荐
cindershade1 小时前
让每条前端异常都能回答:该由谁修、为什么现在修
前端
bitbrowser2 小时前
Telegram提示尝试次数过多,换网络和重装有用吗
前端
研☆香2 小时前
前端简单的的变量声明
前端
赵大仁2 小时前
Next.js AI Route Handler 工程化:超时、流式与鉴权
前端·ai·鉴权·next.js·工程化
2601_963870182 小时前
【计算机毕业设计】基于Vue+Spring Boot的助农电商系统的设计与实现
spring boot·后端·课程设计
sun༒2 小时前
Spring @Scheduled 定时任务详解:Cron表达式、执行顺序、优先级与并行调度
java·后端·spring
yuhaiqiang2 小时前
从这两件事就能看出 vibecoding 距离专业作品差距有多大?AI 能抹平技术,但抹不平品味 !
前端·后端·程序员
一次旅行3 小时前
DeepSeek‑V4‑Flash‑Vision‑Exp 小白入门实战|3种传图方式、完整可跑代码、避坑排障
java·前端·人工智能