Day 16 从零教学:AI 混合检索所有专有名词深度讲解
本文档面向 零 AI 基础 的 Java 开发者,逐层拆解 Day 16 项目中涉及的每一个概念。
阅读顺序:从上到下,前面的概念是后面概念的基础。
目录
- [第一部分:AI 大模型基础](#第一部分:AI 大模型基础)
- [1.1 LLM(大语言模型)](#1.1 LLM(大语言模型))
- [1.2 Token(词元)](#1.2 Token(词元))
- [1.3 Context Window(上下文窗口)](#1.3 Context Window(上下文窗口))
- [1.4 Temperature(温度系数)](#1.4 Temperature(温度系数))
- [1.5 Prompt / Prompt Engineering(提示词 / 提示工程)](#1.5 Prompt / Prompt Engineering(提示词 / 提示工程))
- [1.6 Streaming / SSE(流式输出)](#1.6 Streaming / SSE(流式输出))
- [第二部分:RAG 基础](#第二部分:RAG 基础)
- [2.1 RAG(检索增强生成)](#2.1 RAG(检索增强生成))
- [2.2 Embedding(嵌入向量)](#2.2 Embedding(嵌入向量))
- [2.3 向量检索](#2.3 向量检索)
- [2.4 余弦相似度 vs 余弦距离](#2.4 余弦相似度 vs 余弦距离)
- [2.5 PGVector](#2.5 PGVector)
- 第三部分:混合检索核心技术
- [3.1 为什么要混合检索](#3.1 为什么要混合检索)
- [3.2 N-gram(中文关键词匹配)](#3.2 N-gram(中文关键词匹配))
- [3.3 ILIKE(PostgreSQL 模糊匹配)](#3.3 ILIKE(PostgreSQL 模糊匹配))
- [3.4 RRF(Reciprocal Rank Fusion,倒数排名融合)](#3.4 RRF(Reciprocal Rank Fusion,倒数排名融合))
- [3.5 Reranker / Cross-Encoder(重排序器)](#3.5 Reranker / Cross-Encoder(重排序器))
- [3.6 Bi-Encoder vs Cross-Encoder(双塔 vs 交叉编码器)](#3.6 Bi-Encoder vs Cross-Encoder(双塔 vs 交叉编码器))
- 第四部分:模型详解
- [4.1 DeepSeek-V3](#4.1 DeepSeek-V3)
- [4.2 BGE 系列模型](#4.2 BGE 系列模型)
- [4.3 硅基流动(SiliconFlow)](#4.3 硅基流动(SiliconFlow))
- [4.4 OpenAI 兼容接口](#4.4 OpenAI 兼容接口)
- [第五部分:Java 技术栈](#第五部分:Java 技术栈)
- [5.1 LangChain4j](#5.1 LangChain4j)
- 第六部分:算法与公式推导
- [6.1 RRF 公式推导](#6.1 RRF 公式推导)
- [6.2 长度惩罚公式](#6.2 长度惩罚公式)
- [6.3 完整三阶段流水线数学表达](#6.3 完整三阶段流水线数学表达)
- 第七部分:降级与容错设计
第一部分:AI 大模型基础
1.1 LLM(大语言模型)
是什么?
LLM = Large Language Model,用海量文本训练出来的超大规模神经网络。
你可以这样理解:把互联网上所有公开的书籍、文章、代码、对话全部"喂"给一个超级大脑,它学到的不是"记住原文",而是一种语言模式------知道"你好"后面大概率跟"!",知道"1+1="后面大概率跟"2",知道"中国的首都是"后面必然是"北京"。
核心原理:自回归生成
LLM 生成文本的方式非常简单------每次只预测下一个词:
输入:"今天天气" → 模型输出:"真" (概率最高)
输入:"今天天气真" → 模型输出:"好" (概率最高)
输入:"今天天气真好" → 模型输出:"," (概率最高)
...
这个过程叫 自回归(Autoregressive)------把上一步的输出拼回去当输入,循环往复。
为什么它"懂"东西?
训练时用了 Transformer 架构 ,其中最核心的机制叫 Self-Attention(自注意力):
当模型读到"小明把苹果给了小红,她很开心"时,Self-Attention 会让"她"去"看"前面所有的词,发现"小红"和"她"的注意力权重最高 → 所以"她"指"小红"。
这种"让每个词看所有其他词"的能力,是 LLM 理解上下文的核心。
本项目中
Day 16 使用的 LLM 是 DeepSeek-V3,通过硅基流动平台调用。
1.2 Token(词元)
是什么?
Token 是 LLM 处理文本的最小单位,不是"字"也不是"词",介于两者之间。
中文 vs 英文的 Token
英文:"Hello world" → ["Hello", " world"] = 2 个 token
中文:"你好世界" → ["你好", "世界"] = 2 个 token
混合:"我love你" → ["我", "love", "你"] = 3 个 token
中文通常 1 个汉字 ≈ 0.6~1 个 token ,英文 1 个单词 ≈ 1~1.3 个 token。
为什么重要?
| 维度 | 解释 |
|---|---|
| 计费 | 硅基流动按 token 收费(输入+输出),1M token ≈ 几毛到几块钱 |
| 上下文窗口 | DeepSeek-V3 最大 128K token,超出直接报错 |
| 生成长度 | 设定 max_tokens=1024,模型最多输出 1024 个 token 就停 |
直观感受
"码哥科技的核心产品是什么" = 约 12 个 token
一本《三体》 ≈ 80 万汉字 ≈ 40~50 万 token
1.3 Context Window(上下文窗口)
是什么?
LLM 能"看到"的最大文本长度,单位是 token。
类比
想象你在读一本书,但你只能记住最近 5 页的内容。翻到第 6 页时,第 1 页的内容就从你的记忆里消失了。
LLM 也一样------超出窗口的内容会被截断,前面的对话它"忘记"了。
实际影响
DeepSeek-V3 上下文窗口:128K token ≈ 一本 20 万字的小说
对话示例:
第 1 轮:用户发 500 token,模型回 500 token → 还剩 127000
第 2 轮:用户发 500 token,模型回 500 token → 还剩 126000
...
第 N 轮:剩 0 → 最早的对话开始被截断,"遗忘"
这就是为什么长对话中需要对历史做摘要 或裁剪 ------Day 5 的 RedisChatMemoryStore 就是为了管理这个。
本项目中
Day 16 没有自己管理对话记忆,每次请求是独立的。RAG 场景中的文档片段会被拼进 Prompt 里(== 参考资料 == 部分),所以文档太长也会挤占上下文窗口。
1.4 Temperature(温度系数)
是什么?
控制 LLM 输出随机性的参数,范围通常 0~2。
工作原理
模型输出每个 token 时,其实是给所有可能的词分别打了一个概率分:
输入:"今天天气真"
模型内部概率分布(温度=1 时):
"好" → 85%
"不错"→ 5%
"热" → 3%
"差" → 2%
"烂" → 1%
...
温度的作用:越高 → 概率分布越"平" → 低概率词被选中的机会增大 → 输出更多样话(但也更容易胡说八道)。
温度 = 0.0 → ["好": 100%, ...] → 每次输出完全相同(最确定)
温度 = 0.3 → ["好": 95%, ...] → 偶尔变一下(适合事实性问答)
温度 = 1.0 → ["好": 85%, "不错": 5%, ...] → 正常创意
温度 = 1.5 → ["好": 50%, "棒": 20%, "酷": 10%, ...] → 开始放飞
选值建议
| 温度 | 适用场景 | 原因 |
|---|---|---|
| 0.0 ~ 0.3 | 事实性问答、代码生成、数学推理 | 需要准确、一致 |
| 0.5 ~ 0.8 | 通用对话、翻译 | 自然 + 稳定 |
| 0.9 ~ 1.5 | 创意写作、头脑风暴 | 需要多样性 |
本项目中
Day 16 设置为 0.3------知识库检索回答需要准确,不能胡编。
1.5 Prompt / Prompt Engineering(提示词 / 提示工程)
Prompt 是什么?
Prompt = 你发给 LLM 的指令文本。LLM 不"知道"要做什么,全靠你告诉它。
差 Prompt:"回答问题" → 模型不知道该答什么格式
好 Prompt:"你是一个知识库助手。请根据以下资料回答用户问题。
如果资料不足以回答,请如实说'我不知道'。" → 模型知道角色、数据源、边界
Prompt Engineering 是什么?
设计 Prompt 的技巧和方法论。核心原则:
| 原则 | 示例 |
|---|---|
| 给角色 | "你是一个资深 Java 工程师" → 模型用专业语气 |
| 给约束 | "用 3 句话以内回答" → 控制长度 |
| 给格式 | "用 JSON 格式返回" → 结构化输出 |
| 给示例(Few-shot) | "Q: 什么是RAG? A: RAG是..." → 示范期望格式 |
本项目中
SearchController.java 的 /rag/chat 端点组装了这样一个 Prompt:
你是一个知识库助手。请根据以下参考资料回答用户问题。
如果参考资料不足以回答,请如实说明。
== 参考资料 ==
【资料1】码哥科技成立于2023年...
【资料2】核心产品包括码哥AI中台...
== 用户问题 ==
码哥科技的核心产品是什么?
这就是典型的 RAG Prompt 模板------给角色 + 给数据 + 给边界。
1.6 Streaming / SSE(流式输出)
问题
LLM 生成一个回答可能需要 10~30 秒。如果等全部生成完再一次性返给前端,用户只能干等。
方案:流式输出
模型每生成一个 token,立刻推送给前端------打字机效果。
传统方式(阻塞):
用户问 → [等待 15 秒] → 完整回答一次性出现
流式方式(SSE):
用户问 → "RAG"→"是"→"检索"→"增强"→"生成"... 逐字出现
技术实现:SSE(Server-Sent Events)
SSE 是 HTTP 协议的一种用法:服务器持续推送数据到客户端,单向(服务器→客户端)。
HTTP 响应头:
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
数据格式:
data: {"token": "RAG"}
data: {"token": "是"}
data: {"token": "一种"}
data: [DONE]
本项目中
Day 16 的 openAiStreamingChatModel Bean 就是为此准备的。当前 /search 和 /rag/chat 端点还是阻塞式,但流式能力已配置好,后续可接入 SSE 端点。
第二部分:RAG 基础
2.1 RAG(检索增强生成)
问题:LLM 的两大硬伤
硬伤 1:知识截止日期
LLM 的训练数据是某个时间点之前的。DeepSeek-V3 的训练数据截止到 2024 年中。你问它"2026 年世界杯冠军是谁?",它不可能知道。
硬伤 2:幻觉(Hallucination)
LLM 的本质是"预测下一个词",不是"查询数据库"。当你问它一个它不知道的问题时,它不会说"我不知道",而是会编一个看起来合理的答案------这就是幻觉。
RAG 的解决思路
不用 LLM 记忆知识 → 把知识存在外部 → 提问时检索相关片段 → 拼进 Prompt → 让 LLM"照着资料回答"
完整流程
用户:"码哥科技的核心产品是什么?"
│
┌────▼────┐
│ ① 检索 │ ← 在知识库中找相关文档
│ │ 找到:5 段关于码哥科技产品的文本
└────┬────┘
│
┌────▼────┐
│ ② 增强 │ ← 把检索到的文档拼进 Prompt
│ │ "参考资料:码哥科技的产品有AI中台、智能客服..."
└────┬────┘
│
┌────▼────┐
│ ③ 生成 │ ← LLM 看着参考资料回答
│ │ "码哥科技的核心产品包括码哥AI中台、智能客服..."
└─────────┘
三个词各对应一步:Retrieval → Augmented → Generation。
本质
RAG = 给 LLM 装了一个搜索引擎,让它随时翻书查资料,而不是靠训练记忆。
本项目中
Day 16 实现了 RAG 的 "检索" 部分(还是增强版------混合检索+Reranker),/rag/chat 端点把"增强"(组装 Prompt)也做了,LLM 调用留给调用方。
2.2 Embedding(嵌入向量)
问题:计算机怎么"理解"文字?
计算机只会算数字。要让计算机判断两段话"意思差不多",需要先把文字转成数字。
解决方案:Embedding
Embedding 模型把任意文本映射为一个固定长度的浮点数数组(向量):
"苹果是一种水果" → [0.012, 0.845, -0.321, ..., 0.567] ← 1024 个数字
"香蕉也很好吃" → [0.015, 0.840, -0.318, ..., 0.562] ← 1024 个数字
"我昨天坐高铁去北京" → [-0.823, -0.120, 0.721, ..., 0.098] ← 1024 个数字
神奇之处:语义相近的文本,向量也相近。前两句都是讲水果,它们的向量在高维空间里距离很近;第三句讲交通,向量就离得很远。
类比:地图坐标
想象每个城市有一个经纬度坐标。要判断"北京和南京哪个离上海更近",算一下距离就行------不需要看懂"北京"这两个字。
Embedding 就是给每个文本片段算一个"语义经纬度",只不过不是 2 维,而是 1024 维。
向量维度
为什么 1024 维?维度越高,"表达能力"越强------能区分的语义细节越多。但也有上限(维度诅咒),1024 是业界主流选择。
本项目中
使用 BAAI/bge-large-zh-v1.5 模型,输出 1024 维向量,专门针对中文优化。
流程:
用户问句 "码哥科技的产品是什么"
│
▼ Embedding 模型
[0.0123, 0.8456, ...] ← 1024 个 float
│
▼ PGVector 向量检索
找最近的 20 条文档向量
2.3 向量检索
是什么?
在向量数据库中找出与查询向量最相似的 N 条记录。
类比:二维码扫描
你拍了一张照片(Query Embedding),在相册(向量数据库)里找最像的照片(最相似的文档向量)。"像不像"由距离决定。
常见距离度量
| 名称 | 公式 | 适用场景 |
|---|---|---|
| 余弦距离 | 1 - cos(A, B) |
文本语义相似度(本项目使用) |
| 欧氏距离 | √Σ(aᵢ - bᵢ)² |
图像、物理空间 |
| 点积 | A · B |
深度学习输出层 |
本项目中
PGVector 的 <=> 运算符计算余弦距离:
sql
SELECT *, 1 - (embedding <=> query_vector) AS score
FROM day4_rag_store
ORDER BY embedding <=> query_vector
LIMIT 20
embedding <=> query_vector 返回 0~2(0=完全相同),1 - 距离 转为 0~1 的相似度分数。
2.4 余弦相似度 vs 余弦距离
直观理解
把两个向量想象成从原点出发的两支箭(箭头):
余弦相似度 = 两支箭的"夹角"
- 夹角 0° → 方向完全一致 → 相似度 = 1(最相关)
- 夹角 90° → 互相垂直 → 相似度 = 0(无关)
- 夹角 180° → 方向完全相反 → 相似度 = -1(反向相关)
余弦距离 = 1 - 余弦相似度
- 距离 = 0 → 向量完全一致
- 距离 = 1 → 向量垂直(无关)
- 距离 = 2 → 向量完全相反
为什么用余弦而不是欧氏距离?
考虑这两句话:
A: "我非常喜欢这部电影" → embedding_A
B: "我非常非常非常喜欢这部电影" → embedding_B(字数多,向量"更长")
欧氏距离:A 和 B 的距离很大(因为向量长度不同)
余弦距离:A 和 B 的距离很小(因为方向相同,只是长度不同)
语义相关的文本,余弦比欧氏距离更鲁棒------它只看"方向"(语义),不关心"长度"(文本长度)。
2.5 PGVector
是什么?
PostgreSQL 的一个扩展插件,让 PostgreSQL 能存储和检索向量。
安装方式
sql
CREATE EXTENSION vector; -- 启用扩展
建表示例
sql
CREATE TABLE day4_rag_store (
embedding_id UUID PRIMARY KEY,
text TEXT,
metadata JSONB,
embedding vector(1024) -- 1024 维向量类型
);
核心运算符
| 运算符 | 含义 | 返回值 |
|---|---|---|
<=> |
余弦距离 | 0, 2,0=完全相同,2=完全相反 |
<-> |
欧氏距离 | [0, ∞) |
<#> |
内积(负值) | (-∞, ∞) |
为什么要用 PGVector 而不是专用向量数据库(Milvus/Qdrant)?
| 维度 | PGVector | Milvus |
|---|---|---|
| 部署复杂度 | 一个容器搞定(已有 PG) | 需要单独的集群 |
| 百万级数据量 | ✅ 足够 | ✅ 也可以 |
| SQL 兼容性 | ✅ 可以 JOIN 业务表 | ❌ 独立系统 |
| 学习成本 | 低(会用 PG 就会) | 中等 |
对学习项目来说,PGVector 是最佳选择------零额外部署,功能完全够用。
第三部分:混合检索核心技术
3.1 为什么要混合检索
纯向量检索的短板
向量检索基于语义相似度,在以下场景会翻车:
| 场景 | 向量检索表现 | 原因 |
|---|---|---|
| "API_KEY 配置在哪" | ❌ 找不到 | "API_KEY"是专有名词,Embedding 没见过 |
| "李四的订单" | ❌ 找不到 | 人名向量化后泛化很差 |
| "2024年Q3财报" | ❌ 混入 Q2/Q4 | 数字向量化后精度丢失 |
想想看:Embedding 模型没见过你公司的内部术语"码哥AI中台",它怎么知道"码哥"和什么语义相近?它只能猜,猜对是运气,猜错是常态。
混合检索 = 取长补短
向量检索:擅长"意思差不多的东西"(语义泛化)
+ 关键词检索:擅长"一模一样的东西"(精确匹配)
= 混合检索:两者都行
| 问句 | 向量检索 | 关键词检索(N-gram) | 混合检索 |
|---|---|---|---|
| "产品有什么功能" | ✅ 语义泛化命中 | ❌ 功能太泛 | ✅ |
| "API_KEY 配置" | ❌ 专有名词不准 | ✅ 精确 ILIKE | ✅ |
| "李四的退款" | ❌ 人名泛化差 | ✅ "李四"精确 | ✅ |
3.2 N-gram(中文关键词匹配)
问题:中文没有空格
英文天然有空格隔开单词,直接 ILIKE 就能匹配:
英文:text ILIKE '%world%' → 立马找到 "Hello world"
中文没有空格,一整句话连在一起:
中文:"码哥科技核心产品" → 如果直接 ILIKE '%码哥科技核心产品%',基本只能匹配一模一样的长句
N-gram 的解决思路
把句子切碎,用小碎片去匹配:
"码哥科技核心产品"
2-gram(两字一组):
码哥、哥科、科技、技核、核心、心产、产品
3-gram(三字一组):
码哥科、哥科技、科技核、技核心、核心产、心产品
然后用这些小碎片分别做 ILIKE:
sql
CASE WHEN text ILIKE '%科技%' THEN 1 ELSE 0 END +
CASE WHEN text ILIKE '%核心%' THEN 1 ELSE 0 END +
CASE WHEN text ILIKE '%产品%' THEN 1 ELSE 0 END
好处:不再要求整句匹配,"科技"这个词在任何文档中出现都能被命中。文档说"本公司的核心科技产品包括..."照样能命中"科技""核心""产品"三个词条。
为什么选 2~4 字?
| N-gram 长度 | 效果 |
|---|---|
| 1-gram(单字) | "科""技""产""品" --- 毫无意义 |
| 2-gram | "科技""核心""产品" --- 最有用的词组 |
| 3-gram | "科技核""核心产" --- 多一个字的上下文 |
| 5-gram+ | "码哥科技核" --- 太长,泛化能力差 |
为什么只取前 8 个?
每个 N-gram 在 SQL 中是一个 CASE WHEN,8 个代表 8 个条件分支。太多会拖慢查询,实际 8 个已经能覆盖绝大部分问句的关键信息。
3.3 ILIKE(PostgreSQL 模糊匹配)
是什么?
PostgreSQL 的不区分大小写的 LIKE 运算符。
| 写法 | 行为 |
|---|---|
text LIKE '%hello%' |
区分大小写:只匹配 "hello",不匹配 "HELLO" |
text ILIKE '%hello%' |
不区分大小写:匹配 "hello"、"HELLO"、"Hello" |
语法
% = 任意字符(包括零个)
ILIKE '%科技%' → 匹配:"科技"、"核心科技产品"、"科技股份有限公司"、"科技"
ILIKE '科技%' → 匹配:"科技"、"科技公司"(以"科技"开头)
ILIKE '%科技' → 匹配:"科技"、"核心科技"(以"科技"结尾)
为什么本项目用 ILIKE 而不是全文索引?
| 方案 | 优点 | 缺点 |
|---|---|---|
| ILIKE | 简单、灵活、零配置 | 全表扫描,百万级数据会慢 |
| PostgreSQL 全文索引 | 快(倒排索引) | 需要配置分词器、建索引 |
| Elasticsearch | 专业全文搜索 | 需要额外部署 |
项目只有少量文档(知识库种子数据),ILIKE 全表扫描完全够快(毫秒级),不需要全文索引。
3.4 RRF(Reciprocal Rank Fusion,倒数排名融合)
核心问题:怎么把两路不同的分数合在一起?
向量检索返回的是余弦相似度 (01),关键词检索返回的是**命中次数**(08)。这两个分数不在同一个世界,直接加权怎么调都不对。
RRF 的绝妙思路
不看绝对分数,只看排名位置。
问句:"码哥科技的产品"
向量检索排名: 关键词检索排名:
1. 文档A (0.92) 1. 文档C (6分)
2. 文档B (0.88) 2. 文档A (5分)
3. 文档D (0.71) 3. 文档B (3分)
... ...
RRF_score(文档A) = 1/(60+1) + 1/(60+2) = 0.01639 + 0.01613 = 0.03252
RRF_score(文档B) = 1/(60+2) + 1/(60+3) = 0.01613 + 0.01587 = 0.03200
RRF_score(文档C) = 1/(60+20)+ 1/(60+1) = 0.01250 + 0.01639 = 0.02889 (向量没排进 top20,按 20+1)
RRF_score(文档D) = 1/(60+3) + 1/(60+20)= 0.01587 + 0.01250 = 0.02837
最终 RRF 排序:A > B > C > D
文档 A 在两路都表现很好(第 1 和第 2),胜出。文档 C 只在关键词那路排第 1,综合来看不如 A。
为什么 k=60?
这是 TREC(文本检索会议,信息检索领域顶级评测)论文中的经典取值。
k 的作用是"平滑"------防止排第 1 和排第 2 的权重差距太大:
k=0 时: 排名1=1/1=1.0, 排名2=1/2=0.5 → 差距 2 倍(太大)
k=60 时:排名1=1/61≈0.0164,排名2=1/62≈0.0161 → 差距极小(合理)
k 越大,排名差异越不重要,越"民主"。
3.5 Reranker / Cross-Encoder(重排序器)
问题:RRF 融合后的排序还不够准
RRF 只看排名位置,完全没有"理解"文档内容和问句的关系。排到前面的文档可能只是侥幸在两路都靠前,实际并不相关。
Reranker 做了什么?
Reranker 把 (问句, 候选文档) 成对地喂给一个专门的打分模型,这个模型会"仔细读"问句和文档,给出一个精确的相关性分数。
输入:"码哥科技的核心产品是什么" + "码哥科技成立于2023年,主要做AI落地..."
Reranker 内部:
1. 将问句和文档拼成一对:"[CLS] 码哥科技的核心产品是什么 [SEP] 码哥科技成立于2023年..."
2. 通过 Transformer 自注意力,让问句中的"核心产品"注意到文档中的"AI中台"
3. 输出一个相关性分数:0.9974
为什么这么准?
Reranker 用的是 Cross-Encoder 架构------问句和文档在模型内部互相看到对方,通过自注意力机制发现"核心产品" ↔ "AI中台"这种语义关联。
这是 Embedding 模型(Bi-Encoder)做不到的------Embedding 是分别编码再算距离,问句和文档从没见过面。
API 调用
json
POST https://api.siliconflow.cn/v1/rerank
{
"model": "BAAI/bge-reranker-v2-m3",
"query": "码哥科技的核心产品是什么",
"documents": [
"码哥科技成立于2023年...",
"公司总部位于杭州...",
"核心产品包括AI中台...",
...
],
"top_n": 5
}
返回:
{
"results": [
{"index": 2, "relevance_score": 0.9974}, ← 第 3 篇文档最相关
{"index": 0, "relevance_score": 0.9891},
{"index": 5, "relevance_score": 0.8123},
...
]
}
性能代价
| 阶段 | 复杂度 | 耗时 |
|---|---|---|
| 向量召回(Bi-Encoder) | O(n) | 几十毫秒 |
| Reranker(Cross-Encoder) | O(n × m) | 几百毫秒 ~ 几秒 |
这也是为什么只对 Top-20 做 Rerank,而不是对全部文档------不然太慢。
3.6 Bi-Encoder vs Cross-Encoder(双塔 vs 交叉编码器)
Bi-Encoder(双塔模型)
问句 文档
│ │
┌────▼────┐ ┌────▼────┐
│ Encoder │ │ Encoder │
│ (左塔) │ │ (右塔) │
└────┬────┘ └────┬────┘
│ │
问句向量 [0.12, 0.34...] 文档向量 [0.15, 0.31...]
│ │
└────── 余弦相似度 ──────────┘
↓
相似度分数
两座塔互不相见,各自独立编码,最后用余弦距离算相似度。
- ✅ 速度极快:可以预先算好所有文档向量存数据库
- ❌ 精度不够:问句和文档没见过面,靠"猜"来判断相关性
Cross-Encoder(交叉编码器)
问句 文档
│ │
│ ┌─────────────────────┐ │
└──►│ 同一个 Encoder │◄─┘
│ (互相看见对方) │
└─────────┬───────────┘
│
相关性分数
问句和文档在同一个 Encoder 中交互,通过 Self-Attention 互相"看见"对方。
- ✅ 精度极高:模型可以精确判断"核心产品"和"AI中台"的匹配关系
- ❌ 速度慢:必须现场计算,不能预计算
- ❌ 不能独立存:每对 (问句, 文档) 都要重新跑一遍
本项目的分工
| 阶段 | 模型类型 | 数据量 | 职责 |
|---|---|---|---|
| 阶段 1:向量召回 | Bi-Encoder (BGE Embedding) | 全部文档(粗筛) | 快速粗筛,找到"可能相关"的 |
| 阶段 2:重排序 | Cross-Encoder (BGE Reranker) | 20 条候选 | 精挑细选,找到"真正相关"的 |
这就是工业界标准做法:Bi-Encoder 做粗筛,Cross-Encoder 做精排。好马配好鞍。
第四部分:模型详解
4.1 DeepSeek-V3
| 属性 | 值 |
|---|---|
| 全名 | DeepSeek-V3 |
| 开发商 | 深度求索(DeepSeek) |
| 参数量 | 671B(6710 亿参数),MoE 架构,每次激活约 37B |
| 上下文窗口 | 128K token |
| 输出 | 最大 8K token |
| 发布 | 2024 年 12 月 |
| 开源 | ✅ 开源 |
| 特色 | 中文能力强,数学/代码表现突出,训练成本仅 $5.57M(远低于 GPT-4) |
MoE 是什么?
MoE = Mixture of Experts(混合专家),意思是不一次激活全部参数:
GPT-4:1750 亿参数全部参与计算(稠密模型)
DeepSeek-V3:6710 亿参数,但每次推理只激活 370 亿(MoE)
→ 参数量更大但计算量差不多,性价比更高
类比:一个 100 人的公司,GPT 是全员开会讨论每个问题,DeepSeek 是只找相关领域的专家开会。
为什么选 DeepSeek-V3?
- 中文最好:国内模型,中文理解和生成能力业界顶尖
- 支持 Function Calling(工具调用):后续 Agent 章节会用到
- 便宜:通过硅基流动调用,约 ¥1/百万 token
- 128K 上下文:可以塞大量文档进去
4.2 BGE 系列模型
| 属性 | 值 |
|---|---|
| 全名 | BAAI General Embedding |
| 开发商 | 北京智源人工智能研究院(BAAI) |
| 开源 | ✅ 完全开源 |
| 旗舰模型 | BGE-M3(多语言、多粒度、多功能) |
本项目使用的模型
| 模型 | 用途 | 维度 | 特色 |
|---|---|---|---|
bge-large-zh-v1.5 |
Embedding(向量化) | 1024 | 专为中文优化,MTEB 中文榜单前列 |
bge-reranker-v2-m3 |
Reranker(重排序) | --- | 多语言 Cross-Encoder,支持中英文 |
BGE 为什么好?
- 专门针对检索场景训练:不是"通用"Embedding,是为"找到相关文档"这个任务特化的
- 支持长文本:v1.5 支持 512 token 输入
- M3 多语言:中英文混合查询不在话下
4.3 硅基流动(SiliconFlow)
是什么?
一个国内的 AI 模型服务平台(Model-as-a-Service),类似 Together AI 或 Fireworks AI。
提供什么?
| 能力 | 说明 |
|---|---|
| 模型托管 | DeepSeek、Qwen、BGE 等开源模型的云端推理 |
| API 接口 | OpenAI 兼容格式(Chat、Embedding、Rerank) |
| 免费额度 | 新用户送 ¥14+,足够学完整个项目 |
| 国内访问 | 无需科学上网,公司网络直连可用 |
为什么用硅基流动而不是直连 DeepSeek 官方?
DeepSeek 官方 API 访问量大时不稳定,硅基流动作为中间层提供了更好的可用性和国内网络直连。
API 地址
https://api.siliconflow.cn/v1
所有接口(Chat、Embedding、Rerank)都在这个 base URL 下。
4.4 OpenAI 兼容接口
是什么?
OpenAI 定义了 GPT 系列模型的 API 格式(请求/响应结构),已经成为事实上的行业标准。
兼容意味着什么?
你写一套代码,换一个 base URL 和 API Key,就能调用不同厂商的模型:
java
// 完全相同的方法调用,只是配置不同:
// 调 OpenAI(需要科学上网):
apiKey = "sk-proj-xxx"
baseUrl = "https://api.openai.com/v1"
// 调硅基流动(国内直连):
apiKey = "sk-qughvq..."
baseUrl = "https://api.siliconflow.cn/v1"
// 调阿里百炼:
apiKey = "sk-xxx"
baseUrl = "https://dashscope.aliyuncs.com/compatible-mode/v1"
这就是 LangChain4j 的 OpenAiChatModel 可以直接对接硅基流动的原因------它们用同一套 API 格式。
Chat Completions API 格式
json
POST /v1/chat/completions
{
"model": "deepseek-ai/DeepSeek-V3",
"messages": [
{"role": "system", "content": "你是知识库助手"},
{"role": "user", "content": "码哥科技的核心产品是什么?"}
],
"temperature": 0.3,
"stream": false
}
返回:
{
"choices": [{
"message": {"role": "assistant", "content": "码哥科技的核心产品包括..."}
}]
}
第五部分:Java 技术栈
5.1 LangChain4j
是什么?
LangChain 的 Java 版本。LangChain 是 Python 生态中最流行的 LLM 应用开发框架,LangChain4j 将其核心思想移植到了 Java。
核心抽象
| 概念 | 对应类 | 解释 |
|---|---|---|
| ChatModel | OpenAiChatModel |
封装 LLM 调用(一问一答) |
| StreamingChatModel | OpenAiStreamingChatModel |
封装流式 LLM 调用 |
| EmbeddingModel | OpenAiEmbeddingModel |
封装 Embedding 模型调用 |
| ChatMemory | ChatMemory 接口 |
管理对话历史 |
| AiServices | AiServices 接口 |
声明式 Agent(定义接口,自动生成实现) |
| @Tool | @Tool 注解 |
给 LLM 注册可调用的工具 |
本项目中只用了三个抽象
LangChain4j 功能很多(Chain、Agent、Memory、Retriever、RAG...),但 Day 16 只用到了最基础的三个------Chat、Streaming、Embedding。Reranker 直接使用硅基流动的 /v1/rerank API(通过 HttpClient 直调),因为 LangChain4j 的 Reranker 集成目前还在迭代中。
第六部分:算法与公式推导
6.1 RRF 公式推导
基础公式
RRF_score(d, L₁, L₂, ..., Lₙ) = Σᵢ 1 / (k + rankᵢ(d))
d:某篇文档Lᵢ:第 i 路召回的排序列表rankᵢ(d):文档 d 在第 i 路列表中的排名(1 起始)k:平滑常数,典型值 60
两条路的具体公式
rrf(d) = 1/(60 + rank_vector(d)) + 1/(60 + rank_keyword(d))
为什么用倒数?
排第 1 的贡献 1/61 ≈ 0.0164,排第 10 的贡献 1/70 ≈ 0.0143------差不太多。如果用线性衰减(10 - rank),排第 1 得 9 分,排第 10 得 0 分,差距太大,排名波动影响过度。
为什么不用归一化加权?
方案 A(归一化+加权):
score = 0.6 × (向量相似度) + 0.4 × (关键词命中/8)
问题:
- 0.6 和 0.4 怎么定?靠试,不同数据集最优值不同
- 两个分数的分布完全不同(余弦在 0.6~0.9,关键词在 0~8 分)
归一化到同一区间也会扭曲分布
方案 B(RRF):
score = 1/(60+向量排名) + 1/(60+关键词排名)
优势:
- 不需要调权重(无参)
- 排名是稳定可比的(无论原始分数如何分布)
- k=60 是多年实验验证的固定值
6.2 长度惩罚公式
公式
adjusted_score = raw_score × (1 / (1 + length(text) / 500))
为什么要惩罚长文档?
假设两篇文档都命中了 3 个 N-gram:
- 文档 A:50 字的精确答案段落 → 3/3 词条命中
- 文档 B:5000 字的完整文章 → 偶然也有 3 个词条命中
如果不加长度惩罚,文档 A 和文档 B 得分一样。但实际上文档 A 是精确答案而文档 B 只是偶然命中。
加上长度惩罚后:
文档 A (50字): 3 × 1/(1+50/500) = 3 × 0.909 = 2.73
文档 B (5000字):3 × 1/(1+5000/500) = 3 × 0.091 = 0.27
差距 10 倍。短而精确的片段完胜长篇累牍。
为什么分母是 500?
500 是一个经验拐点------一般认为 500 字是一段"足够精确"的文本片段长度。50 字比 500 字短得多,加分显著;5000 字比 500 字长得多,惩罚显著。
6.3 完整三阶段流水线数学表达
输入:query(用户问句),corpus(文档库)
阶段 1:双路召回
─────────────────
L_vector = vectorSearch(query) = Top-20 by [1 - cosine(emb(q), emb(d))]
L_keyword = keywordSearch(query) = Top-20 by [Σᵢ hit(d, ngramᵢ) × 1/(1+|d|/500)]
where ngrams = generateNgrams(cleanQuery(query))
阶段 2:RRF 融合
─────────────────
for each doc d in (L_vector ∪ L_keyword):
rrf(d) = 1/(60 + rank_vector(d)) + 1/(60 + rank_keyword(d))
L_fused = Top-N by rrf(d) descending (N = |L_vector ∪ L_keyword|, up to 40)
阶段 3:Reranker 精排
─────────────────
L_final = CrossEncoder.rerank(query, L_fused, topN=5)
= argmax_top5( relevance_score(query, d) ) for d in L_fused
输出:L_final(5 条最相关文档)
第七部分:降级与容错设计
7.1 本项目中的降级点
| 阶段 | 可能的失败 | 降级策略 |
|---|---|---|
| 向量检索 | 数据库挂了 | 返回空列表,不能影响关键词检索 |
| 关键词检索 | SQL 语法错误 | 返回空列表,不能影响向量检索 |
| Reranker | API 超时/鉴权失败/模型不可用 | 返回 RRF 融合后的原始 topN |
Reranker 降级代码
java
try {
// 调 Reranker API
return parseResponse(candidates, body);
} catch (Exception e) {
log.error("[Rerank] 失败:{}", e.getMessage());
// 降级:没有 Reranker,RRF 结果也凑合用
return candidates.stream().limit(topN).collect(Collectors.toList());
}
降级原则
部分功能坏了 ≠ 整体服务不可用。Reranker 挂了就降级到 RRF,向量检索挂了就只用关键词------每一层都是独立的,互不拖累。
7.2 为什么这样设计?
用户问了一个问题,你给他 5 条候选文档(即使没有 Reranker,RRF 排序的质量也已经不错了),总比报 500 错误说"服务不可用"强得多。
这就是 Graceful Degradation(优雅降级)------坏了一部分,剩下的还能跑。
附录:术语速查表
| 缩写/术语 | 全称 | 一句话解释 |
|---|---|---|
| LLM | Large Language Model | 大语言模型 |
| Token | --- | LLM 处理的最小文本单位 |
| RAG | Retrieval-Augmented Generation | 检索增强生成:先查资料再回答 |
| Embedding | --- | 文本→向量,语义相近的文本向量也相近 |
| PGVector | --- | PostgreSQL 向量扩展 |
| RRF | Reciprocal Rank Fusion | 倒数排名融合:不看分数看排名 |
| Reranker | --- | 重排序器:精挑细选最相关的文档 |
| Bi-Encoder | --- | 双塔模型:分别编码再算距离 |
| Cross-Encoder | --- | 交叉编码器:联合编码的打分模型 |
| N-gram | --- | N 个连续字符的滑动窗口 |
| ILIKE | --- | PostgreSQL 不区分大小写的模糊匹配 |
| BGE | BAAI General Embedding | 智源研究院的 Embedding/Reranker 模型系列 |
| MoE | Mixture of Experts | 混合专家架构:每次只激活部分参数 |
| SSE | Server-Sent Events | 服务器推送事件(流式输出的 HTTP 方案) |
| BAAI | Beijing Academy of AI | 北京智源人工智能研究院 |
| HikariCP | --- | Spring Boot 默认数据库连接池 |
| Maven | --- | Java 项目构建和依赖管理工具 |
文档版本:v1.1 | 更新日期:2026-06-26 | 对应代码:day16-hybrid-rag
面向读者:有 Java 基础、零 AI 经验的开发者