springboot+langchain4j实战Day 16——混合检索所有专有名词深度讲解

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?

  1. 中文最好:国内模型,中文理解和生成能力业界顶尖
  2. 支持 Function Calling(工具调用):后续 Agent 章节会用到
  3. 便宜:通过硅基流动调用,约 ¥1/百万 token
  4. 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 经验的开发者