Day4
Day4
RAG 基础 + 进阶 + 多轮 + SSE
目标:跑通最小 RAG 链路,理解检索增强生成全流程; 提升 RAG 质量,把 RAG 接进 agent,支持多轮对话和流式输出。
RAG 链路(不分语言):
文档加载 → 切分(chunking) → 向量化 → 存向量库 → 检索 → 重排序 → 生成
-
理解 chunk size / overlap 对检索质量的影响。RAG 优化 + 多轮(不分语言)
-
检索质量优化:query 改写、多 query 检索、重排序(rerank)
-
多轮对话上下文融合:历史消息拼进 prompt
-
把 RAG 作为一个 tool 注册给 agent(agent 自主决定查不查知识库) SSE 流式输出 · 二选一
| Python 路线 | Node 路线 | |
|---|---|---|
| 流式 | FastAPI StreamingResponse + openai stream | NestJS SSE(@Sse 装饰器) / Vercel AI SDK streamText |
Embark AI 知识库
文档加载:把 PDF、Word、Markdown、网页等不同格式的文档解析成纯文本。这一步解决的是"资料进得来"的问题。
切分(chunking):文档太长,没法整篇向量化/塞进 prompt,所以要按段落或语义切成块。块(chunk)就是检索和回答的最小单元,这一步直接影响后面的质量——块太大,信息混杂;块太小,语义被切碎。
向量化(embedding):把每个文本块用 embedding 模型转成一个向量(一串几百维的数字),让语义相近的文本向量距离更近。这是"机器能理解语义"的基础,和关键词匹配是两回事。
存向量库:把"向量 + 原文 + 元数据(标题、来源、位置)"存起来。最小实现甚至不需要专门的向量数据库,内存数组或 SQLite 表都行,检索时全量算一遍相似度即可。
检索(retrieval):把用户问题也转成向量,在库里找最相似的 top-k 个块。最小版本就是"余弦相似度 + 取前几名"。
重排序(rerank):召回是"广撒网",top-k 里可能混着不太相关的块;重排序用更强的模型或规则再精排一次。最小链路可以跳过这一步,属于质量优化。
生成(generation):把检索到的块拼成一个带引用编号的上下文,连同用户问题一起发给 LLM,让它"只能基于检索结果回答",并标注 [ref_1] 之类的引用来源。
RAG 是什么?
一句话:RAG(Retrieval-Augmented Generation,检索增强生成)
就是"先查资料、再写答案"——回答前先从外部知识库检索相关内容,拼进 prompt 让 LLM 基于资料作答。
为什么需要 RAG(三大痛点):
-
幻觉:LLM 不知道的会编。RAG 提供事实依据,要求"没查到就说没查到"。
-
知识时效性:训练数据有截止日期,RAG 的知识库可随时更新。
-
私有/领域知识:公司内部文档不可能进训练集,RAG 是"外挂记忆"。
流程(索引 + 检索生成两阶段):
索引阶段:文档加载 → 切分 → 向量化 → 存向量库
推理阶段:query 向量化 → 检索 top-k → (rerank)→ 拼 prompt → 生成
与微调的对比(常考):
演进:Naive RAG(基础五步)→ Advanced RAG(query 改写、混合检索、rerank、上下文压缩)→ Modular RAG(检索/记忆/路由等模块自由组合)。
面试追问:RAG 检索不到怎么办?答:阈值兜底(minScore)、明确告知无依据、触发重写 query 或换检索策略。你项目里 applyMinScoreThreshold 的 fallback top1 就是典型实现。
Chunking 怎么做?
一句话:把长文档按语义或结构切成固定大小的文本块,块是"向量化的输入单位 + 检索的最小粒度 + 拼 prompt 的基本单元"。
四种常见策略:
三个关键参数:
-
chunk size:影响 embedding 语义质量。太小→语义割裂、上下文不足;太大→语义被稀释、噪声多、占上下文预算。经验值几百到一千 token,按内容定。
-
overlap:相邻块重叠一部分,防止跨块上下文丢失。代价是重复内容占预算、召回重复——所以检索后要做去重(MMR)和拼 prompt 时去重叠。
-
切分边界:优先在段落、句号、标题处切,比硬切质量高很多。
进阶技巧(面试加分):父子块(parent-child)——用小块检索、把父块拼进上下文;邻近块扩展——命中块补前后文。你项目里 context-expander 就是后者。
你项目里的实现:semantic-splitter.ts 用 TextTiling 山谷检测(局部相似度极小值 + 谷深阈值),失败回退 text-splitter.ts 的段落优先机械切分(默认 2000 字符 / 200 重叠,表格感知)。
面试追问:chunk 太大/太小分别有什么影响?
答:太大 embedding 平均化、检索命中不精准、prompt 塞不下;太小检索准但缺上下文,要靠合并/扩展补救。
向量检索原理?
一句话:把文本映射成向量(embedding),用向量距离衡量语义相似度,检索就是"查与 query 向量最接近的 top-k 个向量"。
核心步骤:
文本 → embedding 模型 → 向量(如 1024 维)
query → 同模型 → query 向量
相似度 = cosine(query_vec, chunk_vec) → 取 top-k
相似度度量:
-
余弦相似度:
cos(θ) = A·B / (|A||B|),只看方向不看长度,最常用。归一化后 = 内积。 -
内积:适合已归一化的向量,速度快。
-
欧氏距离:越小越相似,注意与余弦方向相反。
为什么 embedding 能懂语义:训练目标让"语义相近的文本向量距离近",所以"苹果"和"iPhone"的向量比"苹果"和"香蕉"更近,能解决关键词匹配解决不了的同义词问题。
暴力检索 vs ANN(常考):
-
暴力检索:全量算一遍相似度,精确但 O(N),小数据量(几万以内)完全够用。你项目就是 SQLite 存向量 + 内存暴力扫描。
-
ANN(近似最近邻):牺牲一点精度换速度。常用算法:
-
HNSW:多层图结构,上层稀疏图"跳远",下层密集图精查,导航式搜索,工业界最常用。
-
IVF:先聚类,只在最近的几个簇里搜,倒排索引思想。
-
PQ:向量压缩成短码,省内存。
-
LSH:局部敏感哈希,相似项落在同一桶。
-
为什么还要混合检索(高频追问):
向量检索对同义词好,但对专有名词、精确 ID、新词、文件名不敏感;
BM25 关键词检索恰好互补。
融合用 RRF(Reciprocal Rank Fusion):score = Σ 1/(k + rank),只依赖排名不依赖分数,避免不同检索器分数不可比。
你项目 RRF k=60,融合向量 + BM25 + 精确词三路。
面试追问:向量检索缺点?
答:冷启动(新词没语义)、依赖 embedding 质量、维度灾难、ANN 召回可能漏(近似)、存储和计算成本。
Transformer 基础?
Transformer 是"全注意力"架构——不递归、不卷积,用自注意力直接建模序列里任意两个位置的关系,可并行计算,是 GPT/LLaMA 等大模型的基础。
核心公式:
Attention(Q, K, V) = softmax(QKᵀ / √d_k) · V
-
Q(Query):我要找什么;K(Key):我有什么;V(Value):实际内容。
-
QKᵀ算两两相似度 →softmax归一化成权重 → 加权求和 V。 -
除以 √d_k 是缩放:维度大时点积数值会很大,softmax 进入饱和区、梯度消失,缩放保证分布温和。
关键组件:
Mask 的作用:Decoder 自回归生成时只能用"当前及之前"的 token,用 causal mask 把未来位置遮住;BERT 训练用 mask language model 遮住随机词。
并行与复杂度:
-
注意力是矩阵乘法,训练可并行(这是它取代 RNN 的核心原因);RNN 必须串行。
-
复杂度 O(n²),序列越长越贵,所以有大模型长度限制和稀疏注意力/FlashAttention 优化。
-
推理时自回归生成仍需逐步来,但用 KV Cache 缓存历史 K/V,避免每步重算,这是流式输出快的工程基础。
面试追问:
-
Transformer vs RNN:并行 vs 串行、长距离依赖(注意力一步到位,RNN 要逐步传递)vs 梯度衰减。
-
GPT 为什么只用 Decoder?答:Decoder 单向注意力天然适合"根据前文预测下一个 token"的自回归生成。
-
现在主流位置编码?答:RoPE(旋转位置编码),把位置信息编码进 Q/K 的相对角度,外推性好,LLaMA 系列在用。
SSE 细节?
一句话:SSE(Server-Sent Events,服务器推送事件)是基于 HTTP 的单向流式通信,服务端把数据按固定格式一块块推给客户端,天然适合 LLM token 流式输出。
协议格式(Content-Type: text/event-stream):
event: token
data: {"text": "你好"}
id: 42
retry: 3000
event: done
data: {"ok": true}
-
事件之间用空行分隔。
-
data:可以有多行,会被拼接成一条(中间用换行)。 -
event:命名事件类型;不写则默认事件名message。 -
id:+retry:用于断线重连:客户端带Last-Event-ID续传,retry指定重连间隔(毫秒)。 -
服务端要定期发心跳(注释行
: ping或空事件)防止中间代理超时掐断连接。
关键特性:
-
单向:服务器 → 客户端。客户端要发消息用普通 HTTP 请求(这也是 AI 场景够用的原因)。
-
基于 HTTP,兼容性好:普通负载均衡、代理、防火墙都能过;HTTP/2 下多个 SSE 共享一条连接。
-
浏览器两种消费方式:
-
EventSource:自动重连、自动处理 Last-Event-ID,但只支持 GET(发不了 body)。 -
fetch+ReadableStream:可 POST、可带 body/headers(API Key 放 Header),但要自己解析、自己重连。LLM 应用几乎都用这种。
-
-
只能传文本(UTF-8),传 JSON 需自定格式。
实现要点与坑(流式输出必考细节):
-
响应头:
Content-Type: text/event-stream; charset=utf-8、Cache-Control: no-cache、Connection: keep-alive,有些环境还要X-Accel-Buffering: no(Nginx)和关掉代理缓冲,否则 token 被攒住不实时。 -
编码:
TextEncoder把event: xxx\ndata: JSON\n\n编码进ReadableStream。 -
解析:按
\n\n切事件,读event:和data:行,JSON.parse数据。注意 TCP 粘包,缓冲区要留尾巴。 -
中断:客户端
AbortController.abort()→ 服务端request.signal触发,停止生成、关流。 -
结束:发
event: done后关流;LLM 侧收到[DONE]表示完成。 -
中文:确保
charset=utf-8,解码用TextDecoder且要处理流式边界({stream: true})。
你项目里的实现:createChatSseEmitter 统一编码 event: <name>\ndata: <JSON>\n\n;事件有 meta / status / trace / rag-summary / citations / token / knowledge-files / error / done;前端 readSseStream 按 \n\n 切分解析,token 进打字队列,AbortController 支持停止。
SSE vs WebSocket?
一句话:SSE 是"HTTP 上的单向流",WebSocket 是"独立协议上的全双工长连接";LLM 流式输出选 SSE,双向实时交互选 WebSocket。
对比表(背这个就够):
怎么选(面试答法):
-
需求是"服务器持续推、客户端偶尔发一下"→ SSE:更简单、可自动重连、兼容性好。LLM 对话就是典型:用户发一条请求,服务端推几百个 token。
-
需求是"双方高频互推、有二进制数据、低延迟双向"→ WebSocket:如在线聊天、游戏、白板协作。
-
纯 LLM 流式输出几乎不用 WebSocket:多一次协议复杂度,却没有收益——token 本来就是单向的,重发、续传这些 SSE 还更好。聊天产品里用 WS,通常是因为消息收发本身也要走同一条双向通道。
面试追问:SSE 能发 POST 吗?答:EventSource 不行,但 fetch 流式读取可以,所以 AI 项目用 fetch 方案。WebSocket 丢包/断线怎么办?答:自己实现心跳(ping/pong)与重连、消息带序号做去重和补拉。
一句话总结串讲(面试可直接背)
RAG 解决 LLM 幻觉和私有知识问题,核心是"索引 + 检索 + 生成";chunking 决定检索质量,向量检索用余弦相似度找语义近邻,Transformer 用自注意力并行建模长距离依赖;LLM 生成是自回归逐 token 输出,所以用基于 HTTP 的单向 SSE 流式推送最合适——简单、可重连、免代理配置,而 WebSocket 留给需要双向实时通信的场景。
需要的话,我可以把这 6 题整理成一页面试速记卡(含每题的 30 秒口头答法),方便你考前快速过。