手把手做一个图 RAG 烹饪问答系统:Neo4j + Milvus + LLM 的工程实践

「尝尝咸淡」是一个基于图数据库(Neo4j)+ 向量库(Milvus)+ LLM 的智能烹饪问答系统。 它既能回答「红烧肉怎么做」这类单点事实问题,也能回答「有哪些菜同时 用到番茄和鸡蛋」「鸡肉配什么蔬菜好」这类需要多跳推理与集合运算的关系型问题。

本文复盘这套系统的完整实现:从知识库构建、查询路由、三路混合召回、RRF 融合、图 RAG 遍历,到 SSE 流式输出与「取消请求一路传到 LLM」的端到端中止,最后介绍如何用 LLM-as-judge 复现 RAGAS 评估。

代码栈:后端 Node.js + TypeScript + Express(backend2/),前端 React + Ant Design + Zustand(frontend2/),存储 Neo4j + Milvus,模型走 OpenAI 兼容接口(DeepSeek / 通义等)。


一、为什么是「图 RAG」,而不是普通 RAG

普通向量 RAG 的范式是「切块 → 嵌入 → 相似度检索 → 塞给 LLM」。它在单文档事实问答 上很好用,但烹饪领域有大量问题天然是关系型的:

  • 「哪些菜同时 用到鸡蛋和番茄?」------这是集合交集运算;
  • 「鸡肉配什么蔬菜好?」------这是 食材 → 菜品 → 食材 → 蔬菜多跳遍历
  • 「川菜有什么特色?」------这是围绕一个实体的知识子图

向量相似度对「共同出现」「隔几跳关联」这类结构关系几乎无能为力,而这正是图数据库的主场。于是系统采用双库 + 双检索范式

  • Milvus 向量库 + BM25:负责语义/词法召回,解决「这段话和问题像不像」;
  • Neo4j 图数据库:负责结构化关系推理,解决「这些实体之间怎么连」;
  • 上层用一个智能查询路由器决定走哪条路,或者两条路一起走。

整体数据流如下:

scss 复制代码
                          ┌─────────────────────────────────────────────┐
                          │              Express 服务层 (server.ts)        │
                          │   /api/query/stream  (SSE)  /api/query        │
                          └───────────────────┬─────────────────────────┘
                                              │
                          ┌───────────────────▼─────────────────────────┐
                          │        智能查询路由器 (queryRouter.ts)         │
                          │  LLM 分析查询 ──► hybrid_traditional /         │
                          │                  graph_rag / combined         │
                          │  (并行)查询改写 queryRewrite.ts             │
                          └───────┬─────────────────────────┬───────────┘
                                  │                          │
              ┌───────────────────▼──────────┐   ┌───────────▼────────────────┐
              │   传统混合检索 hybridRetrieval │   │   图 RAG  graphRagRetrieval │
              │  ┌─────────┬─────────┬──────┐ │   │  LLM 规划查询类型           │
              │  │图键值双层│ Milvus  │ BM25 │ │   │  multi_hop / intersection  │
              │  │dual_level│ 向量    │ jieba│ │   │  / subgraph → Cypher 遍历  │
              │  └────┬────┴────┬────┴──┬───┘ │   └───────────┬────────────────┘
              │       └─────────┼───────┘     │               │
              │            RRF(k=60) 融合      │               │
              │                 │             │               │
              │          rerank 精排(可降级)  │               │
              │          父文档回填            │               │
              └─────────────────┬─────────────┘               │
                                └──────────┬──────────────────┘
                                           ▼
                          ┌─────────────────────────────────┐
                          │  生成 generation.ts(SSE 流式)    │
                          │  DeepSeek 逐 token,AbortSignal   │
                          └────────────────┬────────────────┘
                                           ▼
                                     前端 React 逐字渲染

离线侧,系统启动时会从 Neo4j 把菜谱知识抽出来,构建四份索引:Milvus 向量索引、BM25 词法索引、内存图 K,V 索引、父文档映射。


二、知识库构建:一份菜谱,四种索引

2.1 图数据建模

知识图谱里有四类节点和三类核心关系:

scss 复制代码
(Recipe:菜谱) ──REQUIRES──────────► (Ingredient:食材)
(Recipe)      ──CONTAINS_STEP────► (CookingStep:步骤)
(Recipe)      ──BELONGS_TO_CATEGORY► (Category:分类)

菜谱文档由 Markdown 解析而来。解析器 markdownParser.ts 用纯正则把 .md 菜谱结构化:抽菜名(第一个 # 标题)、难度(数 ★ 个数)、## 必备原料和工具## 计算(用量,支持「名称:数字 单位」「名称 数字单位」「范围 3~5 个」等多种写法)、## 操作 步骤。

2.2 文档分块:章节优先,滑窗兜底

RAG 里的 chunk 粒度直接决定召回质量。系统的策略在 graphDataPreparation.tschunkDocuments

  • 整篇 ≤ chunkSize(默认 500 字):整篇作为一个 chunk;
  • 否则优先按二级标题 \n## 切分(食材归食材、步骤归步骤,语义内聚);
  • 连标题都没有的长文,退化为固定长度滑动窗口 ,带 chunkOverlap(默认 50 字)重叠,避免把一句话拦腰截断。

每个 chunk 都携带 parent_id(所属菜谱)、chunk_indextotal_chunkssection_title 等元信息------parent_id 是后面「父文档回填」的关键。

2.3 向量索引:Milvus + HNSW + COSINE

向量通道用 milvusIndex.ts:集合字段为 vector(FloatVector) + text + node_id + recipe_name + ...,在 vector 字段上建 HNSW 索引(M=16, efConstruction=200),COSINE 度量

嵌入不本地跑模型,而是调 OpenAI 兼容的 /v1/embeddingsembeddings.ts)。两个细节值得一提:

  1. 向量做 L2 归一化,这样 Milvus 的 COSINE 度量与内积等价,也方便评估阶段直接用点积算余弦相似度;
  2. 维度自适应 :显式传 dimensions 参数让返回向量与集合维度一致;一旦服务商不接受该参数,记住并自动去掉重试,避免后续批次反复报错。

2.4 BM25 词法索引:纯 TS 复刻 Okapi BM25

中文词法召回用「jieba 分词 + Okapi BM25」。分词用 Rust 实现的 @node-rs/jiebajiebaTokenizer.ts),配一份与 Python 版逐字一致的中文停用词表

BM25 本身没有用现成库,而是在 bm25.ts 里照着 rank_bm25.BM25Okapi 纯 TS 实现,参数 k1=1.5, b=0.75, epsilon=0.25。核心打分:

scss 复制代码
score(D, Q) = Σ_q  IDF(q) · tf(q,D)·(k1+1) / ( tf(q,D) + k1·(1 − b + b·|D|/avgdl) )

分母里的长度项让长文档不会因为词多而占优。一个容易被忽略的点是负 IDF 处理 :当一个词出现在过半文档中时,ln((N−df+0.5)/(df+0.5)) 会变成负数(区分度极低),rank_bm25 把这些负 IDF 抬到 epsilon × 平均IDF,避免它们对得分产生负贡献------这里也照搬了:

ts 复制代码
const idfVal = Math.log(this.corpusSize - freq + 0.5) - Math.log(freq + 0.5);
// ...
const floor = this.epsilon * averageIdf;
for (const term of negatives) this.idf.set(term, floor);

jieba 分词对几千个 chunk 是不小的开销,因此系统做了分词结果磁盘缓存 :对语料算一个指纹(每个 chunk 的 chunk_id + md5(content) 累加再 md5),指纹命中就直接加载缓存,跳过全量分词(见 hybridRetrieval.tscomputeCorpusFingerprint / loadTokenizedCache)。

2.5 图 K,V 索引:LightRAG 风格的哈希命中

除了 Neo4j,系统还在内存里维护一份图键值索引graphIndexing.ts),思路借鉴 LightRAG:

  • 每个实体 (菜谱/食材/步骤)以「名称」为索引键,属性拼成自然语言描述存为 value,建立 name → entityId 倒排表;
  • 每条关系 三元组建 KV,索引键除了关系类型,还扩展一组领域主题词
ts 复制代码
if (relationType === 'REQUIRES') {
  keys.push('食材搭配', '烹饪原料', `${source.entity_name}_食材`, target.entity_name);
} else if (relationType === 'BELONGS_TO_CATEGORY') {
  keys.push('菜品分类', '美食类别', target.entity_name);
}

这样「食材搭配」「川菜」这类主题词也能 O(1) 反查到关系。最后做一次去重:同名实体合并(独有信息以「补充信息:」追加)、关系按 (源,目标,类型) 签名去重,并重建倒排表清理悬空引用。


三、查询理解:先「读懂」问题,再决定怎么查

3.1 智能查询路由器

路由器 queryRouter.ts 在检索前先让 LLM 给问题做一次「体检」,输出:

  • 查询复杂度、关系密集度、是否需要多跳/因果/对比/集合推理;
  • 实体数量;
  • 推荐策略hybrid_traditional(传统混合)/ graph_rag(图检索)/ combined(组合);
  • 置信度与决策理由;
  • 用户明确要求的结果条数(「推荐 6 道菜」→ top_k=6)。

Prompt 里固化了路由规则,核心原则是:只要涉及多个实体的交集/组合,哪怕表面简单,也优先 graph_rag,因为图数据库天然擅长交集运算。

LLM 不是万能的,所以有两层兜底:

  • LLM 调用失败 → ruleBasedAnalysis 退化为关键词规则:命中「为什么/如何/区别」提复杂度,命中「同时/一起/都/既...又」提关系密集度;
  • 图 RAG 返回空结果 → 自动 fallback 到传统混合检索,保证「宁可答得普通,不可答不出来」。

还有一个贴心的细节:用户说「推荐 道菜」是模糊数量(交默认),说「推荐6 道菜」是明确数量。代码用正则同时识别阿拉伯数字和中文数字(「三道菜」「十来个」),并把结果夹在 [默认 topK, 20] 区间------不取比默认更小的值,是为了给 RRF/精排/生成留足候选。

3.2 查询改写与扩展(Node 版新增)

用户的真实提问往往口语化、带指代、用别名:「西红柿炒鸡蛋怎么做」里的「西红柿」,在知识库里可能写的是「番茄」。为此 queryRewrite.ts 在检索前用 LLM 做一次「改写 + 扩展」:

  • rewritten:自包含、同义词归一的检索句(番茄↔西红柿、土豆↔马铃薯),供语义通道(向量 / 图关键词 / 图检索)使用;
  • expansions:2~N 个短词法短语(别名、子角度关键词),专供 BM25 做多查询融合。

关键设计是不增加串行延迟 :改写与查询分析是两个独立的 LLM 调用,用 Promise.all 并行,改写的耗时被分析掩盖:

ts 复制代码
const [analysis, rewrite] = await Promise.all([
  this.analyzeQuery(query),
  this.queryRewriter.rewrite(query),
]);

而且全程安全降级 :LLM 失败、未配置、功能关闭时 applied=false,改写句回退为原句、扩展为空,主检索链路不受影响。


四、传统混合检索:三路召回 → RRF 融合 → 精排 → 父文档回填

这是系统的召回主力,hybridRetrieval.ts

4.1 三路召回

① 图键值双层检索(dual_level)。先用 LLM 把查询拆成两层关键词:

  • 实体级 (鸡胸肉、红烧肉、平底锅)→ 在图 KV 索引按实体名精确命中,规则分 0.9,并拼上一跳邻居信息(「相关信息: ...」);命中不足时用 Neo4j 全文索引补充(分数 ×0.7)。
  • 主题级 (减肥、川菜、下饭菜)→ 命中关系 KV(分 0.95)与分类匹配的菜谱(分 0.85);不足时用 Cypher 按 category/cuisineType/tags CONTAINS 补充(分 0.75)。

② 向量通道(vector) 。向 Milvus 要 topK×2 个 COSINE 近邻,对命中节点同样拼一跳邻居,输出原始余弦分。

③ BM25 通道(bm25) 。查询分词后取 BM25 分最高的 topK,丢掉 score ≤ 0 的无关文档。

查询改写在这里发挥作用:语义通道用改写句 ;BM25 则对「原句 + 改写句 + 扩展短语」分别检索,向量对「原句 + 改写句」两路检索,各自先做通道内 RRF 融合 扩大候选池(fuseChannelDocs)。

4.2 RRF 融合:不看分数,看排名

三路召回的分数完全不可比(余弦 0~1、BM25 无界、规则分 0.9/0.95),不能直接加权。系统用 Reciprocal Rank Fusion(RRF, Cormack et al. 2009),只依赖排名:

scss 复制代码
score(d) = Σ_channels  1 / (k + best_rank_channel(d)),  k = 60

每路只取该文档的最佳(最小)排名贡献一次。实现里有几个关键工程点:

  • 去重身份优先用 node_id :同一菜谱的多个 chunk 命中会合并成一个文档;纯内容通道没有 node_id 时回退 md5(前200字),并加 hash:: 前缀防止与真实 id 撞名。
  • 多路命中天然占优 :被两路、三路共同命中的文档累加多个 1/(60+r) 项,排名自动靠前------这正是混合检索想要的「共识即相关」。
  • 代表文档选择 :融合后回填给 LLM 的内容,取全局排名最靠前的那份 chunk;排名相同时按通道优先级 dual > vector > bm25 决胜。
  • 可观测性埋点 :每个结果都记下 rrf_sources(命中哪些通道)、rrf_ranks(各通道最佳排名)、rrf_raw_scores(各通道原始分)、rrf_chunk_hits(各通道命中几个 chunk),供前端「检索过程」面板可视化。

候选预算上,三路各取 candidate_k = max(rrf_k×2, 10) 个候选,融合出 rrf_k 个;开启精排时 rrf_k = max(rerank_candidate_k=20, topK),给精排留足池子。

4.3 Cross-Encoder 精排(可降级)

RRF 之后,可选地调 /rerank 接口(reranker.ts,如 qwen3-rerank)对 (query, doc) 逐对打相关性分、重排取 topK,覆写 final_score

精排以用户原始问题为准 (而非改写/扩展句),避免扩展短语漂移相关性判断。服务未配置或调用失败时,自动降级为 RRF 融合顺序------applied=false,与「模型缺失」时的行为一致。

4.4 父文档回填:召回用小块,生成用整篇

chunk 切得细有利于召回,但 LLM 生成时只看到一个局部步骤(比如「第 3 步:勾芡」)会缺失食材和前序步骤。于是最后做父文档回填attachParentDocuments):对前 parent_doc_top_n(默认 5)个结果,用 parent_id 找到分块前的完整菜谱文档替换其 pageContent(超 4000 字截断),排名和数量不变。

这就是经典的 small-to-big / parent-document retrieval:检索用小块保证命中率,喂给 LLM 用大块保证上下文完整。


五、图 RAG:把自然语言翻译成图遍历

graphRagRetrieval.ts 处理关系型问题,整个模块只有一次 LLM 调用 ------understandGraphQuery 做查询规划,把自然语言映射到图结构,返回:

  • query_typemulti_hop(多跳)/ intersection(交集)/ subgraph(子图)/ clustering(聚类)等;
  • source_entities / target_entities:具体的菜名/食材/菜系;
  • relation_types:从 REQUIRES / BELONGS_TO_CATEGORY / CONTAINS_STEP 中选;
  • max_depth:1~3 的遍历深度。

然后按类型分派:

5.1 多跳遍历(multi_hop)

「鸡肉配什么蔬菜好?」用 Neo4j 变长无向路径 (source)-[*1..depth]-(target),在 Cypher 内直接打分排序:

cypher 复制代码
WITH path, source, target, 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

取前 20 条路径,渲染成 鸡肉 --REQUIRES--> 宫保鸡丁 --REQUIRES--> 花生米 这样的「路径描述文本」喂给 LLM。

5.2 多实体交集(intersection)

「哪些菜同时用到鸡蛋和番茄?」是图数据库的主场。Cypher 里对每个实体先做同义词扩展 (硬编码同义词表:番茄↔西红柿↔圣女果、土豆↔马铃薯↔洋芋......),匹配食材时还排除「酱/汁/粉/油/膏/露」等加工品(避免「番茄酱」误命中「番茄」),要求菜谱命中全部实体组:

cypher 复制代码
UNWIND $entity_groups AS group
UNWIND group.synonyms AS syn
MATCH (r:Recipe)-[:REQUIRES]->(i:Ingredient)
WHERE i.name = syn OR (i.name CONTAINS syn AND NOT ANY(suffix IN [...] WHERE i.name ENDS WITH suffix))
WITH r, collect(DISTINCT group.entity_idx) AS matched_groups
WHERE size(matched_groups) >= $required_count   // 必须命中所有查询实体

回到 JS 侧再按精确命中数打分,未命中全部实体的菜谱直接丢弃。

5.3 知识子图(subgraph / clustering)

「川菜有什么特色?」从源实体出发取 1~depth 跳无向邻居,限制节点数,顺带算出图密度(关系数 / 节点对组合数),把「关于川菜的知识网络,包含 N 个相关概念和 M 个关系」作为上下文。

子图结果有三道空结果守卫(无中心节点 / 匿名孤立中心 / 无邻居无关系),一旦返回空,上层路由器就会回退到传统混合检索。

5.4 combined:图优先的 Round-robin

组合策略下(combinedSearch),传统检索和图检索各跑一份,然后交替穿插 合并:图结果优先(图预算默认 2 个),按 md5(前100字) 去重,保证图结构洞察和文本细节都进上下文。


六、生成:SSE 流式输出,与取消信号的端到端传播

问答接口用 SSE(Server-Sent Events)逐 token 推送,事件序列是:

rust 复制代码
analysis -> { analysis, sources, retrieval_trace }   路由检索完成(先到,先渲染来源)
chunk    -> { content }                              逐 token(0..N 次)
done     -> { elapsed }                              正常结束
error    -> { message }                              出错

6.1 后端:async generator + AbortSignal

生成模块 generation.tsasync generator 产出 token:

ts 复制代码
async *generateAdaptiveAnswerStream(question, docs, signal?: AbortSignal) {
  const stream = await this.client.chat.completions.create(
    { model, messages, stream: true, ... },
    { signal },                       // ← 取消信号传给 OpenAI SDK
  );
  for await (const chunk of stream) {
    if (signal?.aborted) {            // ← 客户端已断开
      logger.info('客户端已断开,中止流式生成以节省 token');
      return;                         // 停止消费、断开上游、不重试
    }
    const content = chunk.choices?.[0]?.delta?.content;
    if (content) yield content;
  }
}

服务层 server.ts 为每个 SSE 请求建一个 AbortController,并把它的 signal 一路传到 LLM 调用。客户端断开 → abort → OpenAI SDK 中止上游 HTTP 连接 → LLM 提供商立即停止生成 token,不再为用户已经不想看的回答白白烧钱。

这里有一个实打实的坑,代码注释里记录了:

ts 复制代码
// 只能监听 res 的 close:req(IncomingMessage)在请求体被 express.json() 读完后
// 就会触发 close(writableFinished 此时为 false),并不代表客户端断开,
// 监听它会导致 abort 被误触发、整个 SSE 响应静默丢弃。
res.on('close', () => {
  if (!res.writableFinished) abort.abort();   // 响应未正常结束 = 客户端提前断开
});

也就是说,必须监听 responseclose 并用 writableFinished 区分「正常结束」与「中途断开」;监听 request 的 close 会在请求体读完时就误判。

中止与错误处理也做了区分:中止不重试、不降级 (用户都走了,重试没意义);网络错误才重试 (3 次,指数退避),流式彻底失败还会降级到非流式一次性返回。写入前也会检查 res.writableEnded / res.destroyed,避免向已销毁的 socket 写数据抛错。

6.2 前端:fetch + ReadableStream 手解 SSE

浏览器原生 EventSource 只支持 GET、无法带 POST body,所以前端 sse.tsfetch + ReadableStream 手动解析 text/event-stream

ts 复制代码
const reader = resp.body.getReader();
while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  buffer += decoder.decode(value, { stream: true });
  buffer = buffer.replace(/\r\n?/g, '\n');        // CRLF 归一化,否则 '\n\n' 永远匹配不到
  let sep;
  while ((sep = buffer.indexOf('\n\n')) >= 0) {   // 事件以空行分隔
    const raw = buffer.slice(0, sep);
    buffer = buffer.slice(sep + 2);
    // 解析 event:/data:,按事件类型分发 onAnalysis/onChunk/onDone/onError
  }
}

「停止生成」按钮复用同一个 AbortControllerabortController.abort() 会让 fetch 抛出 AbortError,前端静默处理(不算错误),后端则如上节所述中止上游。切换会话时也会先 abort 并保存已生成的内容。


七、质量评估:用 LLM-as-judge 复现 RAGAS

RAGAS 是 Python 专有库、JS 生态没有等价物。Node 版不引入 Python 依赖,而是在 evaluation.ts按 RAGAS 的算法口径,用「judge LLM + embeddings」复现四项核心指标

指标 算法 是否需参考答案
faithfulness 忠实度 把答案拆成独立事实陈述,逐条核查能否由检索上下文支持;得分 = 被支持陈述数 / 总陈述数
answer_relevancy 答案相关性 由答案反推 N 个问题,与原问题分别 embedding 求余弦相似度取均值 否(需 embeddings)
context_recall 上下文召回率 把参考答案逐句切分,核查每条能否被检索上下文覆盖
context_precision 上下文精确率 按检索顺序逐片段判断是否相关,用平均精度 AP(MRR 风格)奖励「相关片段排得靠前」

以上下文精确率为例,AP 的计算奖励相关结果排在前面:

ts 复制代码
// rel[] 是按检索顺序的 0/1 相关序列
for (let k = 0; k < rel.length; k++) {
  if (rel[k]) {
    rolling++;
    precisionSum += rolling / (k + 1);   // P@k
  }
}
return precisionSum / totalRelevant;      // AP

工程上,judge 调用走全局信号量限流RAGAS_MAX_WORKERS,默认 2)+ 指数退避重试 (缓解 429/超时);评估用 temperature=0 保证确定性;单指标/单样本失败兜底为 null(对应 RAGAS 的 NaN),不中断整批。评估有两个入口:对内置烹饪测试集跑完整 RAG 管线 + 4 指标,或从会话历史挑一条回答在线评估。


八、检索过程可观测:把「为什么推荐它」摆上台面

RAG 系统最容易被诟病的是「黑盒」。系统把整条检索链路的中间结果都通过 retrieval_trace 随 SSE 返回(system.tsretrievalTraceFromDocuments),前端用 RetrievalTracePanel.tsx 折叠面板展示:

  • 查询改写:原句 → 改写句 + 扩展短语;
  • 图查询规划:判定的查询类型、源/目标实体、关系类型、遍历深度;
  • 图推理路径:走了哪几条路径、最深几跳、途经哪些节点(按节点类型着色);
  • 三路融合统计:各通道候选数 / 最终入选数 / 贡献了几条结果;
  • 精排信息:是否启用、候选池大小、是否实际生效。

每个来源文档还带 rrf_sources / rrf_ranks / rrf_raw_scores,可以直观看到「这道菜是被三路共同命中,还是仅单路命中」------既帮用户建立信任,也帮开发者调参。


九、工程实践:让系统在真实环境里活得下来

这套系统有不少「不是算法、但决定能不能上线」的设计:

  • 全链路优雅降级 :未配置 EMBEDDING_* → 跳过向量通道(BM25 + 图仍可用);rerank 失败 → 退回 RRF 顺序;LLM 失败 → 规则路由 / 关键词兜底;图 RAG 出空 → 回退传统检索;judge 失败 → 指标 null。任何一个外部服务挂掉,主链路都尽量可用。
  • 配置热更新 :设置页改 LLM/embedding/rerank 的 base_url / api_key / model 后,持久化到 settings.json原地重建客户端 ,不用重启服务;系统未就绪时也能先改配置再触发重新初始化(system.tsupdateSettings)。
  • 菜谱上传的增量索引:上传一份 Markdown 菜谱后,解析 → 写 Neo4j → 写 Milvus → 重建 BM25(BM25 不支持增量添加,整体重建但复用分词缓存)→ 增量更新内存图 KV 索引 → 注册父文档,新菜谱立即可被检索到。
  • 后台异步初始化 + 就绪探针 :服务启动后在后台构建知识库,期间 /api/health 返回 initializing,业务接口在未就绪时返回 503 + 提示,避免「启动就能请求、一请求就报错」。
  • 文件编码兼容 :上传的 .md 先按 UTF-8 严格解码,失败再用 GBK 兜底(iconv-lite),覆盖国内 Windows 常见编码。
  • 统一错误契约 :所有错误响应统一 {detail},对标 FastAPI 的 HTTPException,前端一处处理。

十、小结

这套图 RAG 烹饪助手的核心思路可以浓缩成几句话:

  1. 用对工具:事实/语义查找交给向量 + BM25,关系/交集/多跳推理交给图数据库,用 LLM 路由器在两者间分发;
  2. 融合靠排名而非分数:三路召回用 RRF(k=60) 融合,天然让「多路共识」的结果胜出,规避了分数不可比的难题;
  3. 小块召回、大块生成:章节切块提升命中率,父文档回填保证 LLM 拿到完整上下文;
  4. 图 RAG 一次规划、按需遍历:LLM 只负责把问题翻译成查询类型和实体,具体的多跳/交集/子图计算下推给 Cypher;
  5. 流式要能「真取消」res.close + AbortController + SDK signal 把取消信号端到端传到 LLM,及时止损 token;
  6. 默认会失败:每个外部依赖都准备降级路径,每个 LLM 输出都准备规则兜底;
  7. 可观测性内建:把改写、路由、融合、图路径全摆给用户看,让 RAG 不再是黑盒。

RAG 的工程魅力正在于此:单个算法都不神秘,难的是把切块、召回、融合、重排、图遍历、流式、降级、评估这些环节缝成一个在真实环境里稳定、可信、可调试的整体。希望这套实践能为你构建自己的领域 RAG 系统提供一份可落地的参考。

相关推荐
小小善后师18 分钟前
HID 设备对接技术解析:基于本地中间服务的 WebSocket 通信模式
前端
newerp19 分钟前
Golang 切片底层结构
后端·程序员·go
黄油面包20 分钟前
Codex 额度三天见底后,我重新做了一周预算
前端·人工智能
PedroQue9923 分钟前
v2.7.1:修复 H5 端返回死循环闪烁问题
前端·uni-app
coderCN24 分钟前
Nodejs express+knex(ORM框架)
前端·node.js
求道於盲28 分钟前
python中的抽象类
前端
BingoGo34 分钟前
免费可商用 PHP 管理后台 CatchAdmin V5.4.0 发布,新增短信服务能力
后端·php
Csvn35 分钟前
🐍 Day 8:面向对象编程
后端·python