「尝尝咸淡」是一个基于图数据库(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.ts 的 chunkDocuments:
- 整篇 ≤
chunkSize(默认 500 字):整篇作为一个 chunk; - 否则优先按二级标题
\n##切分(食材归食材、步骤归步骤,语义内聚); - 连标题都没有的长文,退化为固定长度滑动窗口 ,带
chunkOverlap(默认 50 字)重叠,避免把一句话拦腰截断。
每个 chunk 都携带 parent_id(所属菜谱)、chunk_index、total_chunks、section_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/embeddings(embeddings.ts)。两个细节值得一提:
- 向量做 L2 归一化,这样 Milvus 的 COSINE 度量与内积等价,也方便评估阶段直接用点积算余弦相似度;
- 维度自适应 :显式传
dimensions参数让返回向量与集合维度一致;一旦服务商不接受该参数,记住并自动去掉重试,避免后续批次反复报错。
2.4 BM25 词法索引:纯 TS 复刻 Okapi BM25
中文词法召回用「jieba 分词 + Okapi BM25」。分词用 Rust 实现的 @node-rs/jieba(jiebaTokenizer.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.ts 的 computeCorpusFingerprint / 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_type:multi_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.ts 用 async 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(); // 响应未正常结束 = 客户端提前断开
});
也就是说,必须监听 response 的 close 并用 writableFinished 区分「正常结束」与「中途断开」;监听 request 的 close 会在请求体读完时就误判。
中止与错误处理也做了区分:中止不重试、不降级 (用户都走了,重试没意义);网络错误才重试 (3 次,指数退避),流式彻底失败还会降级到非流式一次性返回。写入前也会检查 res.writableEnded / res.destroyed,避免向已销毁的 socket 写数据抛错。
6.2 前端:fetch + ReadableStream 手解 SSE
浏览器原生 EventSource 只支持 GET、无法带 POST body,所以前端 sse.ts 用 fetch + 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
}
}
「停止生成」按钮复用同一个 AbortController:abortController.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.ts 的 retrievalTraceFromDocuments),前端用 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.ts 的updateSettings)。 - 菜谱上传的增量索引:上传一份 Markdown 菜谱后,解析 → 写 Neo4j → 写 Milvus → 重建 BM25(BM25 不支持增量添加,整体重建但复用分词缓存)→ 增量更新内存图 KV 索引 → 注册父文档,新菜谱立即可被检索到。
- 后台异步初始化 + 就绪探针 :服务启动后在后台构建知识库,期间
/api/health返回initializing,业务接口在未就绪时返回 503 + 提示,避免「启动就能请求、一请求就报错」。 - 文件编码兼容 :上传的
.md先按 UTF-8 严格解码,失败再用 GBK 兜底(iconv-lite),覆盖国内 Windows 常见编码。 - 统一错误契约 :所有错误响应统一
{detail},对标 FastAPI 的HTTPException,前端一处处理。
十、小结
这套图 RAG 烹饪助手的核心思路可以浓缩成几句话:
- 用对工具:事实/语义查找交给向量 + BM25,关系/交集/多跳推理交给图数据库,用 LLM 路由器在两者间分发;
- 融合靠排名而非分数:三路召回用 RRF(k=60) 融合,天然让「多路共识」的结果胜出,规避了分数不可比的难题;
- 小块召回、大块生成:章节切块提升命中率,父文档回填保证 LLM 拿到完整上下文;
- 图 RAG 一次规划、按需遍历:LLM 只负责把问题翻译成查询类型和实体,具体的多跳/交集/子图计算下推给 Cypher;
- 流式要能「真取消」 :
res.close+AbortController+ SDK signal 把取消信号端到端传到 LLM,及时止损 token; - 默认会失败:每个外部依赖都准备降级路径,每个 LLM 输出都准备规则兜底;
- 可观测性内建:把改写、路由、融合、图路径全摆给用户看,让 RAG 不再是黑盒。
RAG 的工程魅力正在于此:单个算法都不神秘,难的是把切块、召回、融合、重排、图遍历、流式、降级、评估这些环节缝成一个在真实环境里稳定、可信、可调试的整体。希望这套实践能为你构建自己的领域 RAG 系统提供一份可落地的参考。