字节面试官:\"RAG 不就是给大模型挂个知识库?\

字节面试官:\"RAG 不就是给大模型挂个知识库?\

字节面试官:"RAG 不就是给大模型挂个知识库?"

做了两年 AI 开发,说实话从 Java 转过来这条路走得不轻松。

我之前 Java 写了将近 10 年,Java 相关的东西玩得贼溜,两年前转向 AI 方向,做 RAG 做了快一年半。

前阵子朋友去字节面试,回来跟我说面试官上来就问:"RAG 不就是给大模型挂个知识库嘛,有啥难的?"

我当时就想,这种"这问题问得好、但问题底下坑贼深"的想想都怕。这篇文章我把 RAG 链路每个真正有技术含量的点都说清楚,不讲 PPT,只讲工程里真正踩过的那些事。

文档切分:没人告诉你切粒度怎么定

知识库里的内容是你把 PDF、Word、网页、数据库内容塞进去的,切之前先要解析,这一步就有坑——PDF 里的表格、图片、多列排版,普通解析工具拿出来的文本是乱的,直接切就是在切垃圾。

切分策略大概分这几种,面试经常问:

固定大小切分(Fixed-size Chunking) :按 Token 数量硬切,实现最简单,语义边界全破坏,基本不单独用。

递归字符切分(Recursive Character Splitting) :按分隔符优先级递归切,先找段落分隔,没有再找换行,没有再找句号,是工程里最常用的 baseline。

语义切分(Semantic Chunking) :对每两句之间算嵌入余弦相似度,相似度突然下降的地方就是语义边界,切在那儿,语义完整性最好但计算成本高。

Small-to-Big / Parent-Child Chunking :这是我觉得最值得说的一个——切成小 chunk 用来检索(粒度细、语义准),检索到之后往上找它的父级大 chunk 塞给大模型(上下文完整)。检索用小的、生成用大的,两不误。

# Parent-Child Chunking 核心逻辑
from llama_index.core.node_parser import HierarchicalNodeParser, get_leaf_nodes

parser = HierarchicalNodeParser.from_defaults(
chunk_sizes=[2048, 512, 128] # 三层:文档级、段落级、句子级
)
nodes = parser.get_nodes_from_documents(documents)
leaf_nodes = get_leaf_nodes(nodes) # 只索引最小粒度的叶子节点

# 检索命中叶子节点后,通过 parent_id 拿父级节点的完整内容传给 LLM

chunk 之间一定要保留 overlap,一般 10%~15% 的 Token 重叠,防止关键信息被切断。chunk size 没有银弹,跟你的业务文档平均段落长度强相关,上线前必须跑评估对比。

嵌入模型:召回天花板在这里定死了

文档切好了,每个 chunk 过嵌入(Embedding)模型得到向量,存进向量数据库。

这里有个认知误区: 很多人以为向量数据库是瓶颈,实际上嵌入模型才是 。嵌入模型本质是个 双塔编码器(Bi-Encoder) ,问题和文档各自独立编码成向量,检索时算余弦相似度。速度快,但问题向量和文档向量是在各自的语义空间里独立算的,没有交互,精度有损。

选型上看 MTEB 榜单(Massive Text Embedding Benchmark),分中文、英文、多语言榜。

嵌入模型 fine-tuning 这个点面试经常问:通用嵌入模型在垂直领域(法律、医疗、金融)表现会下滑,因为这些领域术语和通用语料分布差异大。用领域内的正负样本对做对比学习微调,召回率提升 10~20% 是正常的。

维度数量也有取舍:text-embedding-3-large 支持 Matryoshka 压缩,3072 维压到 256 维精度损失可接受,存储和检索速度翻几倍,在高并发场景下值得考虑。

向量索引:HNSW 到底在干啥

向量库里的索引不是说存个数组然后遍历算余弦,那样 O(n) 的复杂度,数据量一上来直接跪。生产里用的是近似最近邻(ANN)算法,牺牲一点召回精度换来亚线性查询速度。

最常用的是 HNSW(Hierarchical Navigable Small World) ,Milvus、Qdrant、Weaviate 默认都是这个。原理简单说:构建一个多层图,底层是全量节点,越往上节点越稀疏。查询时从最顶层少量节点开始贪心地往最近邻方向走,逐层下降,最终在底层找到候选集。类比跳表,但在高维空间里。

HNSW 核心参数:
- M:每个节点最多连接的邻居数,越大图越稠密,精度高但内存和构建时间涨
- ef_construction:构建时搜索候选集大小,越大索引质量越好但构建越慢
- ef_search:查询时搜索范围,越大召回越准但延迟越高

IVF(倒排文件索引)是另一种选择:先聚类分成 nlist 个桶,查询时只搜最近的 nprobe 个桶。内存占用比 HNSW 低,但精度也低一些,适合内存敏感场景。

这里有个经典三角取舍: 精度 vs 延迟 vs 内存 ,没有一种索引全赢,得根据业务 SLA 选。

Query 改写:被严重低估的一环

这是大部分 RAG 教程里讲得最少、但工程里最有价值的一块。用户的原始问题通常不是"好问题",比如"那个合同的事怎么样了",向量检索根本不知道往哪召回。

字节面试官:\"RAG 不就是给大模型挂个知识库?\

HyDE(Hypothetical Document Embeddings)

这个是 2022 年 CMU 的论文提出来的,核心思想很反直觉:用户的问题和知识库里的答案,在嵌入空间里的分布是不一样的。"问题"的向量 ≠ "包含答案的文档"的向量。

HyDE 的做法:先让大模型根据问题 生成一段假设性的回答文档 (不管对不对),然后用这段假设文档的向量去检索,而不是用原始问题向量。

# HyDE 实现核心
def hyde_retrieve(query: str, vectorstore, llm):
# 生成假设文档
hypothetical_doc = llm.invoke(
f"请根据以下问题写一段可能的答案文档(100字左右):{query}"
)
# 用假设文档向量检索,而不是原始问题
results = vectorstore.similarity_search(hypothetical_doc.content, k=10)
return results

因为假设文档的词汇分布、语义风格跟知识库里的文档更接近,向量空间对齐更好,召回率提升非常明显。

Multi-Query 与 Step-Back

Multi-Query 改写:一个问题生成 3~5 个不同角度的改写,各自检索,结果合并去重。处理的是向量检索对措辞敏感的问题。

from langchain.retrievers.multi_query import MultiQueryRetriever

retriever = MultiQueryRetriever.from_llm(
retriever=vectorstore.as_retriever(),
llm=llm # 用 LLM 自动生成多个查询变体
)
docs = retriever.invoke("张三的劳动合同什么时候到期")

Step-Back Prompting:把具体问题退一层,变成一个更抽象的背景性问题,先检索背景知识,再把背景知识 + 原问题一起给大模型推理。适合需要背景领域知识支撑的推理类问题。

多路召回与重排:不是随便加的

多路召回与融合

向量检索(ANN)+ BM25 关键词检索各跑一路,但融合怎么融?直接按分数加权不行——量纲完全不同。工程里用的是 RRF(Reciprocal Rank Fusion) :

RRF(d) = Σ 1 / (k + rank_i(d))

k 是平滑常数(一般取 60),rank_i(d) 是文档 d 在第 i 路排序里的位置。这个公式只用排名,不用分数,天然解决了量纲不统一的问题。

def reciprocal_rank_fusion(results_list: list[list], k: int = 60):
scores = {}
for results in results_list:
for rank, doc in enumerate(results):
doc_id = doc.metadata["id"]
scores[doc_id] = scores.get(doc_id, 0) + 1.0 / (k + rank + 1)
# 按融合分数重排
return sorted(scores.items(), key=lambda x: x[1], reverse=True)

多路召回合并后候选集可能有 30~100 条,直接塞给大模型上下文炸了,这时候 Re-rank 上场。Re-rank 模型是 交叉编码器(Cross-Encoder) :把"问题 + 文档"拼接在一起,走完整的 Transformer attention,输出一个相关性分数。

from sentence_transformers import CrossEncoder

reranker = CrossEncoder("BAAI/bge-reranker-v2-m3") # 中文效果好的 Re-rank 模型
pairs = [(query, chunk.page_content) for chunk in candidate_chunks]
scores = reranker.predict(pairs)

# 按 Re-rank 分数重排,取 top5 送给 LLM
ranked = sorted(zip(scores, candidate_chunks), key=lambda x: x[0], reverse=True)
final_chunks = [chunk for _, chunk in ranked[:5]]

Lost in the Middle 与上下文压缩

大模型对放在上下文开头和结尾的内容注意力最高,中间的内容注意力显著下降。这叫 Lost in the Middle 现象。

影响直接:Re-rank 排序好之后,不要无脑按分数顺序拼接,要把最相关的内容放在 Prompt 的开头或结尾。

def arrange_chunks_for_llm(ranked_chunks: list) -> list:
# Lost in the Middle 应对:最相关的放两端,次相关的放中间
if len(ranked_chunks) <= 2:
return ranked_chunks
result = []
result.append(ranked_chunks[0]) # 最相关放开头
result.extend(ranked_chunks[2:]) # 次相关放中间
result.append(ranked_chunks[1]) # 第二相关放结尾
return result

此外,上下文压缩(Context Compression)也很关键。Re-rank 之后拿到 top5 chunk,但每个 chunk 里可能只有 1~2 句话是真正跟问题相关的,剩下的都是噪声。对每个召回 chunk,用一个小模型或 LLM 抽取其中跟问题真正相关的句子,去掉无关内容。

幻觉抑制与评估机制

即便检索质量已经很好,大模型还是可能"创作"知识库里没有的内容。

Prompt 约束 是第一层;引用溯源 是第二层,要求大模型标注每句话来自哪个来源;忠实度自检 是第三层,用另一个 LLM 对生成的答案做 NLI 验证;拒答机制 是第四层,检索相关性低于阈值时直接拒答。

而对于评估,没有量化就是在瞎优化。RAGAS 框架有四个核心指标:

  • 忠实度(Faithfulness) :判断是否能从检索到的上下文推断出来。
  • 答案相关性(Answer Relevancy) :算生成问题和原始问题的向量相似度。
  • 上下文召回率(Context Recall) :判断 ground truth 是否在检索上下文中。
  • 上下文精准率(Context Precision) :判断检索到的 chunk 对正确答案是否有用。

这四个指标互相制衡,必须根据业务优先级取舍,同时你必须有一个标注好的测评集。

RAG vs 微调,别选错方向

什么时候该用 RAG,什么时候该微调?

RAG 解决的是知识问题 :大模型不知道你的私有数据、实时数据、长尾知识。 微调解决的是行为问题 :模型回答风格不对、格式总是乱、特定任务推理能力弱。

两者都有的话,RAG + Fine-tuning 联合,先微调行为,再 RAG 补知识。微调有个常见误区:用 RAG 能解决的问题去微调。效果还不如好好做 RAG,因为微调的知识会随着数据变化失效,而且是不透明的。

真要做好一个企业级 RAG 系统,不比搭一套稳定的 Java 微服务简单,甚至某些地方更折腾。因为每一环都有深水区,任何环节拉胯,整个链路就塌。

💻 配套实训环境与动手练习

本文涉及的相关技术指令、开发环境与工具链已内置在边学边练在线实验室中,无需繁琐安装配置,随时在浏览器中实践体验:

文章版权声明:除非注明,否则均为边学边练网络文章,版权归原作者所有

最新文章

热门文章

本栏目文章