一、整体架构设计
一个完整的企业级 RAG 系统包含两条核心链路:离线文档处理链路 和在线查询链路,两者通过向量库和反馈系统形成闭环。
┌─────────────────────────────────────────────────────────────────────────────┐
│ 离线文档处理链路 │
│ │
│ 原始文档 │
│ (Word/PDF/MD) │
│ │ │
│ ▼ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────┐ ┌─────────┐ │
│ │ 图片处理 │ → │ 表格处理 │ → │ 文档清洗 │ → │ 切分 │ → │Embedding│ │
│ │ (VLM描述) │ │(线性化/ │ │ │ │ │ │(BGE-zh) │ │
│ │ +原图上传 │ │ 分行切片) │ │ │ │ │ │ │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────┘ └────┬────┘ │
│ │ │
│ ┌───────────▼─────────┐ │
│ │ OpenSearch 索引 │ │
│ │ (向量 + 全文 + 元数据)│ │
│ └───────────▲─────────┘ │
│ │ │
└────────────────────────────────────────────────────────────────┼───────────┘
│
┌────────────────────────────────────────────────────────────────┼───────────┐
│ 在线查询链路 │ │
│ │ │
│ 用户提问 │ │
│ │ │ │
│ ▼ │ │
│ ┌──────────┐ │ │
│ │ Query 改写│ ◄── 短期记忆(Redis 10轮) + 长期偏好(OpenSearch) │ │
│ └────┬─────┘ │ │
│ │ │ │
│ ▼ │ │
│ ┌──────────┐ ┌─────────────────────────────────────────────┤ │
│ │ 混合检索 │ ──► │ 向量检索 ──┐ │ │
│ └────┬─────┘ │ 关键词检索 ─┤── RRF 融合 ── Top-20 │ │
│ │ │ + 权限过滤 ┘ │ │
│ ▼ └─────────────────────────────────────────────┘ │
│ ┌───────────┐ │
│ │意图权重排序 │ ◄── 意图表(intend) + 意图-chunk权重(intend_chunk_weight) │
│ └────┬──────┘ │
│ │ │
│ ▼ │
│ ┌───────────┐ │
│ │Reranker精排│ ──► Top-10 │
│ └────┬──────┘ │
│ │ │
│ ▼ │
│ ┌───────────┐ │
│ │ LLM 生成 │ ──► 返回回答 │
│ └────┬──────┘ │
│ │ │
│ ▼ │
│ ┌───────────┐ ┌──────────────────────────────────────────────┐ │
│ │ 反馈采集 │ ──► │ user_feedback + feedback_chunk 关联 │ │
│ └────┬──────┘ └──────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌───────────┐ │
│ │离线意图学习 │ ──► 相似问题聚类(>85%) → 意图表 + chunk权重更新 │
│ └───────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
两条链路的关系:
- 离线链路负责将文档转化为可检索的知识(向量化 + 索引),是 RAG 的"知识底座"
- 在线链路负责理解用户意图、检索知识、生成回答,是 RAG 的"服务入口"
- OpenSearch 是两条链路的交汇点------离线写入,在线读取
- 反馈系统形成闭环------在线链路采集用户行为,离线学习后反过来优化在线链路的排序策略
二、文档预处理层
文档预处理是整个 RAG 系统的地基。垃圾进,垃圾出------如果预处理做得不好,后面的检索和生成再精妙也无济于事。
2.1 图片处理:让 VLM 成为文档的"眼睛"
企业文档中大量信息承载在图片里------架构图、流程图、配置截图。如果图片只是被当作一个 [image.png] 丢弃,那这些信息就彻底丢失了。
核心思路 :用千问 2.5 VL(视觉语言模型)读取图片内容,将图片替换为 [图片内容:xxx] 的文本描述,同时将原图上传到 MinIO/OSS,在描述后附上图片地址。
处理流程:
python
def process_images_in_document(markdown_content: str, doc_id: str) -> str:
"""
处理 Markdown 文档中的图片:
1. 提取图片引用
2. 用 VLM 生成图片内容描述
3. 上传原图到 MinIO/OSS
4. 替换图片引用为 [图片内容:描述](图片地址)
"""
image_pattern = r'!\[([^\]]*)\]\(([^)]+)\)'
matches = re.findall(image_pattern, markdown_content)
for alt_text, image_path in matches:
# 调用千问 2.5 VL 生成图片描述
description = qwen_vlm.describe_image(
image_path=image_path,
prompt="请详细描述这张图片中的内容,包括文字、流程、架构等关键信息。"
)
# 上传原图到 MinIO/OSS
image_url = minio_client.upload(
bucket=f"rag-images-{doc_id}",
file_path=image_path,
object_name=generate_object_name(image_path)
)
# 替换原始图片引用
original_ref = f""
replacement = f"[图片内容:{description}]({image_url})"
markdown_content = markdown_content.replace(original_ref, replacement)
return markdown_content
运维场景举例:
假设你有一篇运维知识库文档《K8s 集群故障排查手册》,其中有一张 Pod CrashLoopBackOff 的排查流程图:
原始 Markdown:
markdown

处理后:
markdown
[图片内容:Pod CrashLoopBackOff 排查流程图。首先检查 Pod Events 获取错误信息,
然后分支:如果是 OOMKilled 则调大 resources.limits.memory;如果是 ImagePullBackOff
则检查镜像地址和镜像拉取凭证;如果是 Error 则查看容器日志定位具体异常。
每个分支处理后需重新部署验证。](https://minio.internal/rag-images/doc-123/crashloop-flow.png)
这样,当用户问"Pod 一直 CrashLoopBackOff 怎么排查"时,这段图片描述就能被检索到。如果用户需要查看原图,链接也在那里。
更多 VLM 处理示例:
示例 2:监控告警配置截图
原始 Markdown:
markdown

处理后:
markdown
[图片内容:Prometheus 告警规则配置截图。配置了三条规则:1) CPU 使用率超过 80% 持续 5 分钟触发 P2 告警,expr: 100 - (avg by(instance)(rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100) > 80;2) 内存使用率超过 90% 触发 P1 告警;3) 磁盘剩余空间低于 10GB 触发 P2 告警。告警接收渠道为钉钉运维群和 PagerDuty。](https://minio.internal/rag-images/doc-456/alert-config.png)
当用户问"怎么配置 Prometheus CPU 告警"时,这段描述包含了完整的 PromQL 表达式,能被关键词检索精确命中。
示例 3:微服务架构图
原始 Markdown:
markdown

处理后:
markdown
[图片内容:微服务架构图。前端通过 Nginx 网关接入,网关后接 API Gateway(Kong),下游分为用户服务(User Service)、订单服务(Order Service)、支付服务(Payment Service)三个核心微服务,均通过 Nacos 做服务发现。数据层:用户服务连接 MySQL 主从,订单服务连接 PostgreSQL,支付服务连接 Redis + MySQL。消息队列使用 RabbitMQ,连接订单服务和支付服务。](https://minio.internal/rag-images/doc-789/architecture.png)
当用户问"支付服务依赖哪些中间件"或"订单服务和支付服务怎么通信"时,架构图的文本描述能被精确检索到。
VLM Prompt 设计的关键点:
- 区分图片类型:说明性图片(流程图、架构图)要提取结构和文字信息;数据图表要提取数值和趋势。当前场景以说明性图片为主,prompt 聚焦于"描述图中内容、流程、架构"。
- 描述要可检索:生成的描述要包含用户可能搜索的关键词,而不是笼统的"这是一张流程图"。
- 控制长度:图片描述不是越长越好,要控制在 200 字以内,避免切片时图片描述被截断。
2.2 表格处理:让结构化数据可检索
运维文档中表格无处不在------配置参数表、告警规则表、故障对照表、端口映射表。Markdown 表格在切分和检索时面临三个核心问题:切分时表格被截断、检索时表格语义丢失、向量化时行列关系断裂。
2.2.1 表格的三种处理策略
| 策略 | 适用场景 | 原理 | 检索效果 |
|---|---|---|---|
| 保留原文 | 小表格(≤5 行) | 直接保留 Markdown 表格原文 | 关键词检索好,语义检索一般 |
| 线性化 | 中等表格(5-20 行) | 将表格转为自然语言描述 | 语义检索好,关键词检索好 |
| 分行切片 | 大表格(>20 行) | 按行拆分,每行加表头上下文 | 兼顾检索精度和上下文完整性 |
2.2.2 保留原文:小表格直接保留
5 行以内的表格,直接保留 Markdown 原文。切分时确保表格作为一个整体不被拆断。
运维场景举例:Nginx 核心参数表
原始表格:
markdown
| 参数 | 默认值 | 说明 |
|------|--------|------|
| worker_processes | auto | 工作进程数 |
| worker_connections | 1024 | 单进程最大连接数 |
| proxy_read_timeout | 60s | 代理读超时 |
| proxy_connect_timeout | 60s | 代理连接超时 |
处理方式:不转换,原样保留。切分时将整个表格和标题放在同一个 chunk 中。
python
def should_preserve_table(table_rows: int) -> bool:
"""小表格直接保留原文"""
return table_rows <= 5
这种表格的关键词检索效果很好------用户搜"proxy_read_timeout 默认值"能精确命中。
2.2.3 线性化:中等表格转为自然语言
5-20 行的表格,直接保留原文会导致 chunk 过大,且 Embedding 难以捕捉行列间的语义关系。线性化将每行转为一句自然语言描述,既保留了完整信息,又提升了语义检索的召回率。
运维场景举例:Redis 配置参数表
原始表格:
markdown
| 参数 | 默认值 | 范围 | 说明 |
|------|--------|------|------|
| maxmemory | 0(无限制) | 0-可用内存 | Redis 最大内存限制,0 表示不限制 |
| maxmemory-policy | noeviction | 8种策略 | 内存满时的淘汰策略 |
| maxmemory-samples | 5 | 1-100 | LRU/LFU 采样数量,越大越精确但越慢 |
| timeout | 0 | 0-秒 | 客户端空闲超时,0 表示不超时 |
| tcp-keepalive | 300 | 0-秒 | TCP keepalive 检测间隔 |
| save | 900 1 / 300 10 / 60 10000 | 秒/变更次数 | RDB 快照触发条件 |
| appendonly | no | yes/no | 是否开启 AOF 持久化 |
| appendfsync | everysec | always/everysec/no | AOF 刷盘策略 |
线性化后:
markdown
Redis 配置参数说明:
- maxmemory:默认值 0(无限制),范围 0 到可用内存,Redis 最大内存限制,0 表示不限制。
- maxmemory-policy:默认值 noeviction,有 8 种策略可选,内存满时的淘汰策略。
- maxmemory-samples:默认值 5,范围 1 到 100,LRU/LFU 采样数量,值越大越精确但越慢。
- timeout:默认值 0,范围 0 到秒数,客户端空闲超时时间,0 表示不超时。
- tcp-keepalive:默认值 300,范围 0 到秒数,TCP keepalive 检测间隔。
- save:默认值 "900 1 / 300 10 / 60 10000",格式为 秒/变更次数,RDB 快照触发条件,表示 900秒内1次变更或300秒内10次变更或60秒内10000次变更触发快照。
- appendonly:默认值 no,可选 yes 或 no,是否开启 AOF 持久化。
- appendfsync:默认值 everysec,可选 always/everysec/no,AOF 刷盘策略,always 最安全但最慢,no 最快但可能丢数据。
线性化的关键 :不是简单地把表格行拼接,而是补充上下文信息。比如 save 参数的值 900 1 / 300 10 / 60 10000 对普通用户来说不可读,线性化时解释为"900秒内1次变更或300秒内10次变更或60秒内10000次变更触发快照"。
python
def linearize_table(table: MarkdownTable, table_title: str) -> str:
"""
将 Markdown 表格线性化为自然语言描述
"""
headers = table.headers # ["参数", "默认值", "范围", "说明"]
lines = [f"{table_title}:"]
for row in table.rows:
parts = []
for i, cell in enumerate(row):
header = headers[i]
if cell.strip(): # 跳过空单元格
parts.append(f"{header}{cell}")
lines.append(f"- {','.join(parts)}。")
return "\n".join(lines)
为什么线性化比保留原文检索效果更好?
| 查询 | 保留原文(向量检索 Top-1) | 线性化(向量检索 Top-1) |
|---|---|---|
| "Redis 内存满了怎么办" | 未命中(表格中没有"内存满") | 命中 maxmemory-policy(线性化中有"内存满") |
| "AOF 刷盘策略哪个最安全" | 未命中("最安全"不在表格中) | 命中 appendfsync(线性化中有"always 最安全") |
| "RDB 快照触发条件" | 命中 save 行(关键词匹配) | 命中 save 行(语义+关键词都命中) |
线性化补充了表格中隐含的语义信息(如"内存满"对应 maxmemory-policy,"最安全"对应 always),这些信息在原文表格中不存在,但对语义检索至关重要。
2.2.4 分行切片:大表格按行拆分
超过 20 行的大表格,即使线性化也会导致 chunk 过大。按行拆分,每行(或几行)为一个 chunk,但必须附带表头作为上下文。
运维场景举例:告警规则表
原始表格(50+ 行,这里截取部分):
markdown
| 告警名称 | 级别 | 条件 | 通知渠道 | 处理SOP |
|---------|------|------|---------|---------|
| CPU使用率过高 | P2 | >80% 持续5min | 钉钉群 | 检查进程、扩容 |
| 内存使用率过高 | P2 | >90% | 钉钉群 | 检查OOM、重启服务 |
| 磁盘空间不足 | P2 | 剩余<10GB | 钉钉群 | 清理日志、扩容 |
| Pod CrashLoopBackOff | P1 | 持续5min | 钉钉群+电话 | 检查日志、调整资源 |
| Pod OOMKilled | P1 | 触发1次 | 钉钉群+电话 | 调整memory limits |
| Redis内存超限 | P2 | >maxmemory | 钉钉群 | 检查淘汰策略 |
| MySQL慢查询 | P3 | >1s/次 | 邮件 | EXPLAIN分析加索引 |
| MySQL主从延迟 | P2 | >60s | 钉钉群 | 检查binlog、网络 |
| Nginx 502 | P1 | >10次/min | 钉钉群+电话 | 检查上游服务 |
| Nginx 504 | P2 | >5次/min | 钉钉群 | 调整超时参数 |
| ... | ... | ... | ... | ... |
按行拆分,每行附带表头上下文:
Chunk 1:
markdown
告警规则表 - CPU使用率过高:级别 P2,条件 >80% 持续5min,通知渠道 钉钉群,
处理SOP 检查进程、扩容。
(表头参考:告警名称 | 级别 | 条件 | 通知渠道 | 处理SOP)
Chunk 2:
markdown
告警规则表 - Pod CrashLoopBackOff:级别 P1,条件 持续5min,通知渠道 钉钉群+电话,
处理SOP 检查日志、调整资源。
(表头参考:告警名称 | 级别 | 条件 | 通知渠道 | 处理SOP)
Chunk 3:
markdown
告警规则表 - Nginx 502:级别 P1,条件 >10次/min,通知渠道 钉钉群+电话,
处理SOP 检查上游服务。
(表头参考:告警名称 | 级别 | 条件 | 通知渠道 | 处理SOP)
python
def split_large_table(table: MarkdownTable, table_title: str,
rows_per_chunk: int = 3) -> list[str]:
"""
大表格按行拆分,每行附带表头上下文
"""
header_context = " | ".join(table.headers)
chunks = []
for i in range(0, len(table.rows), rows_per_chunk):
batch = table.rows[i:i + rows_per_chunk]
lines = [f"{table_title}:"]
for row in batch:
parts = []
for j, cell in enumerate(row):
if cell.strip():
parts.append(f"{table.headers[j]}{cell}")
lines.append(f"- {','.join(parts)}。")
lines.append(f"(表头参考:{header_context})")
chunks.append("\n".join(lines))
return chunks
为什么必须附带表头? 因为切分后的 chunk 脱离了表格上下文,"P1"、">80% 持续5min"这些值单独出现没有意义。附带表头后,检索系统和 LLM 都能理解这些值的含义。
2.2.5 特殊表格处理
运维文档中还有一些特殊类型的表格,需要针对性的处理策略:
嵌套表格(合并单元格)
Markdown 不支持合并单元格,从 Confluence 或 Word 导出时需要展平:
原始 Confluence 表格:
| 故障类型 | 影响范围 | 处理方式 |
|---------|---------|---------|
| 服务不可用 | 支付服务 | 重启服务 |
| ^ | 订单服务 | 降级处理 |
| ^ | 用户服务 | 无影响 |
展平后:
markdown
| 故障类型 | 影响范围 | 处理方式 |
|---------|---------|---------|
| 服务不可用 | 支付服务 | 重启服务 |
| 服务不可用 | 订单服务 | 降级处理 |
| 服务不可用 | 用户服务 | 无影响 |
键值对表格(参数配置)
这种表格本质上是一组键值对,线性化时可以更简洁:
原始表格:
markdown
| 配置项 | 值 |
|-------|-----|
| cluster.name | prod-cluster |
| node.name | node-01 |
| path.data | /data/es |
| path.logs | /var/log/es |
| network.host | 0.0.0.0 |
| http.port | 9200 |
线性化后(紧凑格式):
markdown
Elasticsearch 集群配置:cluster.name=prod-cluster, node.name=node-01,
path.data=/data/es, path.logs=/var/log/es, network.host=0.0.0.0, http.port=9200
键值对表格用 key=value 格式比自然语言更紧凑,且关键词检索时 cluster.name 和 prod-cluster 都能命中。
对照表(故障-原因-方案)
运维最常见的表格类型,线性化时要突出因果关系:
原始表格:
markdown
| 故障现象 | 可能原因 | 排查命令 | 解决方案 |
|---------|---------|---------|---------|
| Pod OOMKilled | 内存限制过低 | kubectl describe pod | 调大 resources.limits.memory |
| Pod OOMKilled | 内存泄漏 | go tool pprof / jmap | 排查代码修复泄漏 |
| ImagePullBackOff | 镜像地址错误 | kubectl describe pod | 修正 image 字段 |
| ImagePullBackOff | 无拉取凭证 | kubectl get secret | 创建 imagePullSecrets |
| CrashLoopBackOff | 启动命令错误 | kubectl logs | 修正 entrypoint/command |
| CrashLoopBackOff | 配置文件缺失 | kubectl exec -- ls | 检查 ConfigMap 挂载 |
线性化后(强调因果链):
markdown
K8s 故障排查对照表:
- 故障现象 Pod OOMKilled,可能原因 内存限制过低:排查命令 kubectl describe pod,
解决方案 调大 resources.limits.memory。
- 故障现象 Pod OOMKilled,可能原因 内存泄漏:排查命令 go tool pprof 或 jmap,
解决案 排查代码修复泄漏。
- 故障现象 ImagePullBackOff,可能原因 镜像地址错误:排查命令 kubectl describe pod,
解决方案 修正 image 字段。
- 故障现象 ImagePullBackOff,可能原因 无拉取凭证:排查命令 kubectl get secret,
解决方案 创建 imagePullSecrets。
- 故障现象 CrashLoopBackOff,可能原因 启动命令错误:排查命令 kubectl logs,
解决方案 修正 entrypoint/command。
- 故障现象 CrashLoopBackOff,可能原因 配置文件缺失:排查命令 kubectl exec -- ls,
解决方案 检查 ConfigMap 挂载。
这样当用户问"Pod OOMKilled 怎么查"时,语义检索能命中"内存限制过低"和"内存泄漏"两个原因,并且排查命令和解决方案都在一起。
2.2.6 表格处理的完整流水线
python
def process_tables_in_document(markdown_content: str, doc_id: str) -> str:
"""
处理 Markdown 文档中的表格:
1. 识别所有表格
2. 根据表格大小和类型选择处理策略
3. 小表格保留原文,中等表格线性化,大表格分行切片
"""
tables = extract_markdown_tables(markdown_content)
for table in tables:
table_title = find_table_title(table) # 查找表格前的标题
if is_key_value_table(table):
# 键值对表格:紧凑格式线性化
processed = linearize_kv_table(table, table_title)
elif is_comparison_table(table):
# 对照表:强调因果链线性化
processed = linearize_comparison_table(table, table_title)
elif len(table.rows) <= 5:
# 小表格:保留原文,确保切分时不拆断
processed = table.raw_markdown
mark_as_atomic(processed) # 标记为不可拆分单元
elif len(table.rows) <= 20:
# 中等表格:线性化
processed = linearize_table(table, table_title)
else:
# 大表格:分行切片
chunks = split_large_table(table, table_title)
processed = "\n\n".join(chunks)
markdown_content = markdown_content.replace(table.raw_markdown, processed)
return markdown_content
表格处理策略选择流程图:
识别表格
│
├─ 键值对表格? ──── 是 ──→ 紧凑格式线性化(key=value)
│
├─ 对照表? ──────── 是 ──→ 因果链线性化(现象→原因→方案)
│
├─ 行数 ≤ 5? ────── 是 ──→ 保留原文(标记不可拆分)
│
├─ 行数 ≤ 20? ──── 是 ──→ 标准线性化
│
└─ 行数 > 20? ──── 是 ──→ 分行切片(每行附表头)
2.3 文档清洗
文档清洗的目标是去除噪音、统一格式、保留语义。需要注意,清洗应在表格处理之后执行,避免清洗规则误伤已经线性化的表格内容。常见的清洗规则:
| 清洗项 | 处理方式 | 原因 |
|---|---|---|
| 重复空行 | 合并为单个空行 | 影响切分边界判断 |
| 不可见字符 | 去除零宽字符、BOM 等 | 可能导致 Embedding 异常 |
| 页眉页脚 | 正则匹配去除 | 重复内容污染检索 |
| 目录索引 | 去除自动生成的目录 | 与正文重复 |
| 水印文字 | 正则去除 | 噪音 |
| HTML 标签残留 | 转换为纯文本或 Markdown | 语义解析需要干净文本 |
运维场景举例:
运维文档常见问题------从 Confluence 导出的 HTML 带大量冗余标签,从监控系统导出的 PDF 带页眉"监控告警平台 | 第 X 页"。这些如果不清洗,检索时"监控告警平台"会出现在每个 chunk 中,严重干扰关键词检索的相关性。
更多清洗场景举例:
| 场景 | 原始内容 | 清洗后 | 影响 |
|---|---|---|---|
| Confluence 宏标签 | {info:title=注意}生产环境禁止直接操作{info} |
注意:生产环境禁止直接操作 |
去除宏语法,保留语义 |
| PDF 页眉 | `运维手册 v2.3 | 第 12 页` | (删除) |
| 重复目录 | 1.1 概述...1.2 架构...(目录页) |
(删除自动生成目录) | 目录和正文重复,降低检索精度 |
| 表格合并单元格 | HTML <td colspan="3"> |
Markdown 合并表格 | 保留表格结构,避免信息丢失 |
| 代码块语言标注缺失 | ` ```代码块```` | ` ```bash\n代码块\n```` | 帮助切分器识别代码块边界 |
| 零宽字符 | K8s |
K8s |
零宽字符导致 Embedding 编码异常 |
清洗前后对比示例:
一个从 Confluence 导出的运维变更文档,清洗前:
html
<div class="confluence-information-macro confluence-information-macro-warning">
<span class="aui-icon aui-icon-small aui-iconfont-warning confluence-information-macro-icon"></span>
<div class="confluence-information-macro-body">
<p>变更前必须通知相关方,并获得变更委员会审批</p >
</div>
</div>
<p>变更步骤:</p >
<ol><li>停止服务</li><li>备份数据</li><li>执行变更</li><li>验证结果</li></ol>
清洗后:
markdown
> ⚠️ 变更前必须通知相关方,并获得变更委员会审批
变更步骤:
1. 停止服务
2. 备份数据
3. 执行变更
4. 验证结果
清洗后的 Markdown 既保留了语义(警告信息、步骤列表),又去除了 HTML 噪音,而且标题层级清晰,方便后续切分。
python
def clean_document(content: str, source_type: str) -> str:
"""文档清洗流水线"""
cleaners = [
remove_invisible_chars, # 去除不可见字符
normalize_whitespace, # 合并多余空行
remove_headers_footers, # 去除页眉页脚
remove_duplicate_lines, # 去除连续重复行
]
if source_type == 'confluence':
cleaners.insert(0, clean_html_tags) # Confluence 导出带 HTML
elif source_type == 'monitoring_pdf':
cleaners.insert(0, remove_page_headers) # 监控 PDF 去页眉
for cleaner in cleaners:
content = cleaner(content)
return content
2.4 格式转换:Word/PDF → Markdown
文档切分的前提是文档有结构化的表示。Markdown 是最理想的中间格式------它保留了标题层级、列表、代码块等语义结构,同时又足够简单,方便后续切分。
Word → Markdown:
推荐使用 mammoth 或 docx2md。mammoth 的优势是保留标题层级,这对切分至关重要:
python
import mammoth
def word_to_markdown(file_path: str) -> str:
with open(file_path, "rb") as docx_file:
result = mammoth.convert_to_markdown(docx_file)
return result.value # Markdown 文本
PDF → Markdown:
PDF 是最头疼的格式,因为 PDF 本质是排版描述,不是结构化文档。推荐方案:
- 文本型 PDF :使用 marker 或 pdfplumber,保留标题层级和表格结构
- 扫描型 PDF:先 OCR(如 PaddleOCR),再用上述工具处理
- 混合型 PDF:marker 支持混合处理,同时调用 OCR 和文本提取
python
from marker.convert import convert_single_pdf
def pdf_to_markdown(file_path: str) -> str:
"""使用 marker 将 PDF 转换为 Markdown,保留结构"""
full_text, _, _ = convert_single_pdf(file_path)
return full_text
运维场景举例:
运维团队通常有大量 PDF 格式的故障复盘报告、变更操作手册。这些 PDF 往往混合了文字和截图------文字部分可直接提取,截图部分需要走前面提到的 VLM 处理流程。一个典型的处理链路:
变更手册.pdf → marker 转换 → Markdown(含图片引用)→ VLM 处理图片 → 清洗 → 可切分的干净 Markdown
更多格式转换示例:
示例 1:故障复盘报告(PDF)
一份《6.15 支付服务故障复盘》PDF 包含:故障时间线表格、影响范围统计图、根因分析流程图、修复步骤截图。转换流程:
复盘报告.pdf
→ marker 提取文字部分(故障时间线、根因分析文本)
→ VLM 处理:影响范围统计图 → [图片内容:故障影响范围统计图。受影响服务:支付服务100%不可用,订单服务30%超时,用户服务无影响。故障时长47分钟,影响订单量约12000笔]
→ VLM 处理:根因分析流程图 → [图片内容:5-Why根因分析图。现象:支付接口超时 → 原因1:数据库连接池耗尽 → 原因2:慢查询占用连接 → 原因3:缺失索引 → 根因:order_create_time字段未建索引]
→ VLM 处理:修复步骤截图 → [图片内容:数据库加索引操作截图。执行SQL: ALTER TABLE payment_order ADD INDEX idx_create_time(order_create_time),执行时间0.3秒]
示例 2:Word 格式的运维 SOP
一份《生产环境发布 SOP》Word 文档包含审批流程、回滚步骤、检查清单。Word 转 Markdown 的关键是保留标题层级,因为 SOP 的步骤依赖层级关系:
# 生产环境发布 SOP
## 发布前检查
### 代码审查
### 测试报告确认
## 发布流程
### 灰度发布
### 全量发布
## 回滚方案
### 自动回滚触发条件
### 手动回滚步骤
如果标题层级丢失,"回滚步骤"就会和"发布步骤"混在一起,检索"如何回滚"时可能返回的是发布步骤。
示例 3:Confluence 导出的运维知识库
Confluence 是运维团队最常用的知识库平台。导出的 HTML 需要特别注意:
- 宏(Macro)处理:Confluence 的 info、warning、code 宏需要转换为 Markdown 对应语法
- 表格处理:Confluence 表格可能包含合并单元格,需要展平
- 附件链接:Confluence 的附件链接需要替换为 MinIO/OSS 上的实际地址
三、文档切分层
切分是 RAG 系统中最被低估的环节。切得太粗,检索不精准;切得太细,丢失上下文。好的切分策略要在"检索精度"和"上下文完整性"之间找到平衡。
3.1 Markdown 结构化切分
Markdown 的标题层级天然提供了切分边界。核心原则:以语义单元为切分单位,而不是以字数为切分单位。
推荐的切分策略:
一级标题(#)→ 作为文档分片的最大边界
二级标题(##)→ 作为首选切分点
三级标题(###)→ 在二级标题内容过长时作为补充切分点
关键参数:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| chunk_size | 512 tokens | 单个 chunk 的目标大小 |
| chunk_overlap | 64 tokens | 相邻 chunk 的重叠区域 |
| 最小 chunk 大小 | 100 tokens | 过小的 chunk 合并到父级 |
| 最大 chunk 大小 | 1024 tokens | 超过则按段落再切分 |
python
from langchain.text_splitter import MarkdownHeaderTextSplitter
def chunk_markdown(content: str, doc_id: str) -> list[dict]:
"""按 Markdown 标题层级切分"""
headers_to_split_on = [
("#", "h1"),
("##", "h2"),
("###", "h3"),
]
splitter = MarkdownHeaderTextSplitter(
headers_to_split_on=headers_to_split_on
)
chunks = splitter.split_text(content)
# 二次处理:过大的 chunk 按段落再切分,过小的 chunk 合并
result = []
for chunk in chunks:
if len(chunk.page_content) > MAX_CHUNK_SIZE:
# 按段落再切分
sub_chunks = split_by_paragraph(chunk, overlap=CHUNK_OVERLAP)
result.extend(sub_chunks)
elif len(chunk.page_content) < MIN_CHUNK_SIZE:
# 标记为待合并
result.append(mark_for_merge(chunk))
else:
result.append(normalize_chunk(chunk, doc_id))
return merge_small_chunks(result)
运维场景举例:
一篇《Nginx 故障排查指南》的 Markdown 文档:
markdown
# Nginx 故障排查指南
## 502 Bad Gateway
### 常见原因
1. 上游服务不可达
2. 上游服务响应超时
3. Nginx 配置错误
### 排查步骤
1. 检查上游服务状态:`curl http://upstream:8080/health`
2. 检查 Nginx 错误日志:`tail -f /var/log/nginx/error.log`
3. 检查 Nginx 配置:`nginx -t`
## 504 Gateway Timeout
### 常见原因
上游服务处理时间超过 proxy_read_timeout
### 排查步骤
1. 调整 proxy_read_timeout 参数
2. 优化上游服务性能
3. 检查网络延迟
按标题切分后,会得到以下 chunk:
- Chunk 1 :
502 Bad Gateway下的所有内容(常见原因 + 排查步骤) - Chunk 2 :
504 Gateway Timeout下的所有内容
这样当用户问"Nginx 502 怎么排查"时,检索到的 chunk 包含完整的 502 排查步骤,而不是一个碎片化的片段。
更多切分场景举例:
示例 2:运维 SOP 文档的切分
一份《生产环境变更 SOP》结构如下:
markdown
# 生产环境变更 SOP
## 变更审批流程
(200 字,描述审批节点和审批人)
## 变更前检查清单
### 基础设施检查
(300 字,检查服务器、网络、存储状态)
### 依赖服务检查
(250 字,检查下游服务和中间件状态)
### 回滚方案确认
(400 字,回滚触发条件和回滚步骤)
## 变更执行步骤
### 灰度发布
(500 字,灰度策略、流量切换、监控指标)
### 全量发布
(300 字,全量发布条件和执行方式)
## 变更后验证
(350 字,功能验证、性能验证、告警检查)
切分结果:
- Chunk 1:变更审批流程(200 字,独立 chunk)
- Chunk 2:变更前检查清单(950 字,三个三级标题合并,因为单独每个都太小)
- Chunk 3:变更执行步骤(800 字,灰度+全量合并)
- Chunk 4:变更后验证(350 字,独立 chunk)
当用户问"变更前要检查什么"时,Chunk 2 包含了完整的检查清单;当用户问"灰度发布怎么做"时,Chunk 3 包含了灰度和全量发布的完整流程。
示例 3:代码片段的切分
运维文档中经常包含 Shell 脚本和配置文件。代码块应该作为整体切分,不要在代码中间断开:
markdown
## MySQL 备份脚本
以下是 MySQL 全量备份脚本:
```bash
#!/bin/bash
BACKUP_DIR=/data/backup/mysql
DATE=$(date +%Y%m%d)
mysqldump -u root -p$MYSQL_PASSWORD \
--all-databases \
--single-transaction \
--quick \
> $BACKUP_DIR/all_$DATE.sql
切分时,整个代码块应该和标题"MySQL 备份脚本"放在同一个 chunk 中。如果代码块超过 chunk_size 上限,应该在代码块之前截断,而不是在代码中间截断。
*示例 4:表格的切分*
运维文档中的配置参数表格应该保持完整:
```markdown
## Nginx 核心参数
| 参数 | 默认值 | 说明 |
|------|--------|------|
| worker_processes | auto | 工作进程数 |
| worker_connections | 1024 | 单进程最大连接数 |
| proxy_read_timeout | 60s | 代理读超时 |
| proxy_connect_timeout | 60s | 代理连接超时 |
这个表格不大,应该和标题放在同一个 chunk。如果表格很大(如 50+ 行参数),可以按逻辑分组拆分。
3.2 切分的元数据标注
每个 chunk 除了文本内容,还需要携带丰富的元数据,这些元数据将在后续检索中发挥关键作用:
python
@dataclass
class Chunk:
chunk_id: str # 唯一标识
doc_id: str # 文档 ID
content: str # 文本内容
embedding: list[float] # 向量
metadata: dict # 元数据
# metadata 示例
metadata = {
"doc_title": "Nginx 故障排查指南",
"source_type": "confluence", # 来源类型
"h1": "Nginx 故障排查指南", # 一级标题
"h2": "502 Bad Gateway", # 二级标题
"h3": "排查步骤", # 三级标题
"department": "基础架构部", # 所属部门(权限过滤用)
"doc_level": "L2", # 文档密级
"created_at": "2024-01-15",
"updated_at": "2024-06-10",
"chunk_index": 3, # 在原文中的位置
"image_refs": [ # 包含的图片引用
"https://minio.internal/rag-images/doc-123/flow.png"
]
}
元数据的用途:
- 部门 + 密级:检索时做权限过滤,确保用户只能看到自己权限范围内的文档
- 标题层级:作为关键词检索的增强字段,提升检索精度
- 时间戳:支持时间衰减排序,优先返回最新文档
- chunk_index:在上下文窗口中恢复原文顺序
四、Embedding 与向量化
4.1 为什么选 BGE-large-zh
在 BGE-large-zh 和 BGE-M3 之间,我们选择 BGE-large-zh,基于以下考量:
| 维度 | BGE-large-zh | BGE-M3 |
|---|---|---|
| 语言支持 | 中文专精 | 多语言(100+) |
| 向量维度 | 1024 | 1024 |
| C-MTEB 得分 | 中文场景更高 | 中文场景略低 |
| 推理速度 | 更快(单语言模型更轻) | 较慢 |
| 私有化部署资源 | 较低 | 较高 |
| 未来扩展性 | 仅中文 | 可扩展到英文 |
选择理由:内部知识库场景以中文文档为主,BGE-large-zh 在中文检索任务上表现更优,且私有化部署资源消耗更低。如果未来需要支持英文文档,可以部署 BGE-M3 作为补充模型,而非替换。
4.2 向量化流水线
python
from FlagEmbedding import FlagModel
model = FlagModel('BAAI/bge-large-zh-v1.5',
query_instruction_for_retrieval="为这个句子生成表示以用于检索相关文章:")
def embed_chunks(chunks: list[Chunk]) -> list[Chunk]:
"""批量向量化 chunks"""
texts = [chunk.content for chunk in chunks]
embeddings = model.encode(texts)
for chunk, embedding in zip(chunks, embeddings):
chunk.embedding = embedding.tolist()
return chunks
def embed_query(query: str) -> list[float]:
"""向量化用户查询,注意使用 query_instruction"""
return model.encode_queries([query]).tolist()[0]
关键细节 :BGE 系列模型要求查询向量化和文档向量化使用不同的 prefix instruction。encode_queries 会自动添加 "为这个句子生成表示以用于检索相关文章:" 前缀,而 encode 用于文档。搞反了这个,检索效果会大幅下降。
4.3 向量化性能优化
中等规模(1-10 万篇文档,约 50-500 万 chunks)的向量化需要注意:
python
# 批量编码,而不是逐条
embeddings = model.encode(texts, batch_size=256)
# 增量索引:只向量化新增/修改的文档
def incremental_embed(doc_id: str, chunks: list[Chunk]) -> list[Chunk]:
"""增量向量化:先检查文档是否已索引"""
existing = opensearch.get_chunks_by_doc_id(doc_id)
if existing:
# 文档已存在,先删除旧索引
opensearch.delete_chunks_by_doc_id(doc_id)
# 重新向量化并索引
return embed_chunks(chunks)
运维场景举例:
运维知识库每周会有新的故障复盘报告和变更操作手册入库。增量索引策略是:检测文档的 updated_at 时间戳,只对新增或修改的文档重新向量化,避免全量重建。
更多 Embedding 实践示例:
示例 1:query 和 doc 编码不能搞反
这是最常见的坑。假设有如下文档 chunk:
"OOMKilled 是 Kubernetes 中 Pod 因内存超限被杀死的常见状态,需要检查容器的 resources.limits.memory 配置是否合理"
正确做法:
python
# 文档向量化(不加 prefix)
doc_embedding = model.encode(["OOMKilled 是 Kubernetes 中 Pod 因内存超限被杀死的常见状态..."])
# 查询向量化(加 prefix)
query_embedding = model.encode_queries(["Pod OOMKilled 怎么排查"])
错误做法:
python
# 错误:查询也用 encode,不加 prefix
query_embedding = model.encode(["Pod OOMKilled 怎么排查"]) # ❌ 检索效果会大幅下降
# 错误:文档也加 prefix
doc_embedding = model.encode_queries(["OOMKilled 是..."]) # ❌ 语义空间不对齐
搞反了之后,向量空间中的 query 和 doc 会处于不同的子空间,余弦相似度无法正确反映相关性。实测中,这个错误会导致召回率下降 30-50%。
示例 2:运维术语的 Embedding 特性
BGE-large-zh 对运维领域术语的向量化有一些有趣的特点:
| 术语对 | 余弦相似度 | 分析 |
|---|---|---|
| "OOMKilled" vs "内存溢出" | 0.78 | 语义相关,但术语不同 |
| "CrashLoopBackOff" vs "Pod 崩溃循环" | 0.82 | 语义接近 |
| "502 Bad Gateway" vs "上游服务不可达" | 0.65 | 因果关系但表述差异大 |
| "CPU 飙升" vs "CPU 使用率高" | 0.91 | 高度相似 |
| "Redis 脑裂" vs "Redis Cluster 分区" | 0.72 | 同一概念的不同表述 |
这解释了为什么纯向量检索不够------"502 Bad Gateway"和"上游服务不可达"语义相关但相似度只有 0.65,可能排在其他不太相关的结果后面。混合检索通过关键词匹配弥补了这个差距。
五、向量存储与混合检索
5.1 为什么选 OpenSearch
OpenSearch 同时支持向量检索(k-NN 插件)和全文检索,是做混合检索的理想选择。相比纯向量数据库(如 Milvus、Qdrant),OpenSearch 的优势在于:
- 一个引擎搞定两种检索:不需要维护两套系统(向量库 + ES),运维成本低
- 混合检索原生支持:可以在一个查询中同时做向量检索和关键词检索
- 权限过滤:支持文档级权限控制,检索时按部门/密级过滤
- 企业级特性:集群管理、快照备份、监控告警,运维团队熟悉
5.2 索引设计
json
{
"mappings": {
"properties": {
"chunk_id": { "type": "keyword" },
"doc_id": { "type": "keyword" },
"content": {
"type": "text",
"analyzer": "ik_max_word",
"search_analyzer": "ik_smart"
},
"embedding": {
"type": "knn_vector",
"dimension": 1024,
"method": {
"name": "hnsw",
"space_type": "cosinesimil",
"engine": "nmslib",
"parameters": {
"M": 16,
"ef_construction": 256
}
}
},
"metadata": {
"properties": {
"doc_title": { "type": "text", "analyzer": "ik_max_word" },
"h1": { "type": "keyword" },
"h2": { "type": "keyword" },
"h3": { "type": "keyword" },
"department": { "type": "keyword" },
"doc_level": { "type": "keyword" },
"created_at": { "type": "date" },
"updated_at": { "type": "date" }
}
}
}
},
"settings": {
"index.knn": true,
"number_of_shards": 3,
"number_of_replicas": 1
}
}
关键设计决策:
- 分词器选择 :
ik_max_word做索引时分最细粒度切分,ik_smart做搜索时智能切分。这是中文检索的标准配置。 - HNSW 参数 :
M=16和ef_construction=256是 1024 维向量的推荐值,平衡了检索速度和召回率。 - metadata 字段用 keyword 类型:h1/h2/h3 用 keyword 而非 text,因为它们用于精确过滤而非模糊搜索。
5.3 混合检索与 RRF 融合
混合检索的核心思想:向量检索捕获语义相似性,关键词检索捕获精确匹配,两者互补。
运维场景举例:
用户问"K8s OOMKilled 排查"。关键词检索能精确匹配"OOMKilled"这个专业术语,但可能遗漏用"内存溢出"表述的文档;向量检索能捕获语义相似性,但可能把"OOM"匹配到其他不相关的缩写。混合检索取两者之长。
RRF(Reciprocal Rank Fusion)算法:
RRF 的核心公式:
RRF_score(d) = Σ 1 / (k + rank_i(d))
其中 k 是平滑常数(通常取 60),rank_i(d) 是文档 d 在第 i 个检索结果中的排名。
python
def rrf_merge(vector_results: list, keyword_results: list, k: int = 60) -> list:
"""
RRF 融合向量检索和关键词检索结果
"""
scores = {}
# 向量检索结果评分
for rank, chunk in enumerate(vector_results):
chunk_id = chunk["chunk_id"]
scores[chunk_id] = scores.get(chunk_id, 0) + 1.0 / (k + rank + 1)
# 保存原始数据
if chunk_id not in scores["_data"]:
scores.setdefault("_data", {})[chunk_id] = chunk
# 关键词检索结果评分
for rank, chunk in enumerate(keyword_results):
chunk_id = chunk["chunk_id"]
scores[chunk_id] = scores.get(chunk_id, 0) + 1.0 / (k + rank + 1)
if chunk_id not in scores.get("_data", {}):
scores.setdefault("_data", {})[chunk_id] = chunk
# 按 RRF 分数排序
sorted_chunks = sorted(
[(cid, score) for cid, score in scores.items() if cid != "_data"],
key=lambda x: x[1],
reverse=True
)
return [scores["_data"][cid] for cid, _ in sorted_chunks]
OpenSearch 中的混合检索查询:
json
{
"size": 20,
"query": {
"bool": {
"filter": [
{ "term": { "metadata.department": "基础架构部" } },
{ "terms": { "metadata.doc_level": ["L1", "L2"] } }
],
"should": [
{
"knn": {
"embedding": {
"vector": [0.01, -0.02, ...],
"k": 20
}
}
},
{
"multi_match": {
"query": "K8s OOMKilled 排查",
"fields": ["content^2", "metadata.h2^1.5", "metadata.h3^1"],
"type": "best_fields"
}
}
]
}
}
}
注意 filter 部分------这就是权限过滤的实现。用户只能检索到本部门且密级允许的文档。
更多混合检索示例:
示例 1:RRF 融合计算过程
用户问"K8s Pod 启动失败",假设向量检索和关键词检索各返回以下结果:
向量检索 Top-5:
| 排名 | Chunk | 向量排名 |
|---|---|---|
| 1 | 《K8s 故障手册-Pod 启动失败排查》 | 1 |
| 2 | 《K8s 故障手册-ImagePullBackOff》 | 2 |
| 3 | 《K8s 运维指南-Pod 生命周期》 | 3 |
| 4 | 《Docker 故障-容器启动异常》 | 4 |
| 5 | 《K8s 故障手册-CrashLoopBackOff》 | 5 |
关键词检索 Top-5:
| 排名 | Chunk | 关键词排名 |
|---|---|---|
| 1 | 《K8s 故障手册-Pod 启动失败排查》 | 1 |
| 2 | 《K8s 故障手册-CrashLoopBackOff》 | 2 |
| 3 | 《K8s 故障手册-ImagePullBackOff》 | 3 |
| 4 | 《K8s 部署文档-Pod 配置说明》 | 4 |
| 5 | 《K8s 故障手册-资源限制与 OOM》 | 5 |
RRF 融合计算(k=60):
| Chunk | 向量得分 | 关键词得分 | RRF 总分 | 最终排名 |
|---|---|---|---|---|
| Pod 启动失败排查 | 1/(60+1)=0.0164 | 1/(60+1)=0.0164 | 0.0328 | 1 |
| CrashLoopBackOff | 1/(60+5)=0.0154 | 1/(60+2)=0.0161 | 0.0315 | 2 |
| ImagePullBackOff | 1/(60+2)=0.0161 | 1/(60+3)=0.0159 | 0.0320 | 3 |
| Pod 生命周期 | 1/(60+3)=0.0159 | - | 0.0159 | 5 |
| Pod 配置说明 | - | 1/(60+4)=0.0156 | 0.0156 | 6 |
可以看到,"Pod 启动失败排查"在两个检索中都是第一名,RRF 融合后稳居第一。"CrashLoopBackOff"虽然向量排名靠后(第5),但关键词排名靠前(第2),融合后排名提升到第2。这就是 RRF 的优势------两种检索互相补充。
示例 2:权限过滤的实际应用
运维部门权限划分示例:
| 部门 | 可见文档密级 | 可见文档范围 |
|---|---|---|
| 基础架构组 | L1, L2 | K8s、Nginx、Redis 运维文档 |
| 数据库组 | L1, L2 | MySQL、PostgreSQL、Redis 文档 |
| 安全组 | L1, L2, L3 | 所有文档 + 安全审计报告 |
| 开发组 | L1 | 基础运维文档,不含 L2 操作手册 |
当基础架构组的用户搜索"Redis 内存优化"时,检索条件中会自动加上:
json
{"bool": {"filter": [
{"term": {"metadata.department": "基础架构组"}},
{"terms": {"metadata.doc_level": ["L1", "L2"]}}
]}}
这样即使 Redis 内存优化的 L3 文档(安全审计相关)存在,也不会被返回给基础架构组的用户。
示例 3:时间衰减排序
运维文档有时效性,去年的故障排查方案可能已经不适用。可以在检索时加入时间衰减因子:
python
def time_decay_score(updated_at: str, half_life_days: int = 180) -> float:
"""
时间衰减函数:文档越新分数越高
half_life_days: 半衰期,默认 180 天(半年)
"""
days_since_update = (datetime.now() - parse(updated_at)).days
return math.exp(-0.693 * days_since_update / half_life_days)
在实际检索中,可以将时间衰减分数与 RRF 分数加权融合:
python
final_score = 0.8 * rrf_score + 0.2 * time_decay_score
这样半年前的文档得分衰减到 50%,一年前的文档得分衰减到 25%,确保用户优先看到最新的运维方案。
六、用户记忆系统
记忆系统是区分" Chatbot"和"智能助手"的关键。没有记忆的系统每次对话都从零开始;有记忆的系统则能理解用户的上下文和偏好。
6.1 短期记忆:Redis 存储最近 10 轮对话
短期记忆解决的是对话连贯性问题。用户说"还有其他方法吗",系统需要知道"其他方法"指的是什么。
python
import redis
import json
redis_client = redis.Redis(host='localhost', port=6379, db=0)
def save_short_term_memory(user_id: str, conversation_id: str,
role: str, content: str, ttl: int = 3600):
"""保存短期记忆,默认 1 小时过期"""
key = f"short_memory:{user_id}:{conversation_id}"
message = json.dumps({"role": role, "content": content})
redis_client.lpush(key, message)
# 只保留最近 10 轮(20 条消息,每轮含 user + assistant)
redis_client.ltrim(key, 0, 19)
redis_client.expire(key, ttl)
def get_short_term_memory(user_id: str, conversation_id: str) -> list[dict]:
"""获取短期记忆"""
key = f"short_memory:{user_id}:{conversation_id}"
messages = redis_client.lrange(key, 0, -1)
return [json.loads(msg) for msg in reversed(messages)] # 按时间正序
运维场景举例:
用户: Nginx 502 怎么排查?
助手: [给出 502 排查步骤]
用户: 试了第一步,上游服务是正常的
助手: [基于上下文,跳过第一步,直接建议检查 Nginx 配置]
用户: 配置也检查过了,没问题
助手: [进一步建议检查连接超时和负载均衡配置]
如果没有短期记忆,第三轮和第四轮的回答就无法基于前文推理。
更多短期记忆场景:
场景 2:故障排查的渐进式对话
用户: 线上 MySQL 慢查询告警了
助手: [给出慢查询排查步骤:1. 查看慢查询日志 2. EXPLAIN 分析 3. 检查索引]
用户: EXPLAIN 显示走了全表扫描
助手: [基于上下文,知道是全表扫描问题,建议添加索引或优化查询条件]
用户: 表有 2000 万行数据,加索引会锁表吗
助手: [基于上下文,知道是大表加索引的问题,建议用 pt-online-schema-change 或 gh-ost 在线加索引]
场景 3:配置修改的上下文追踪
用户: 帮我把 Nginx 的 proxy_read_timeout 从 60s 改成 120s
助手: [给出修改步骤]
用户: 还需要改 proxy_connect_timeout 吗
助手: [基于上下文,知道用户在调整 Nginx 超时配置,建议 proxy_connect_timeout 也可以适当调大]
如果短期记忆只保留 5 轮,上述 3 轮对话加追问可能超出窗口;10 轮基本能覆盖大多数运维排查场景。但也要注意,短期记忆不是越多越好------过多的历史对话会让改写模型分心,反而降低改写质量。
6.2 长期记忆:OpenSearch 存储用户偏好画像
长期记忆解决的是个性化问题。不同用户关注的领域不同,系统应该学会用户的偏好。
用户画像的数据结构:
json
{
"user_id": "u-12345",
"preferences": {
"keywords": [
{"keyword": "K8s", "weight": 0.9, "updated_at": "2024-06-10"},
{"keyword": "Nginx", "weight": 0.8, "updated_at": "2024-06-10"},
{"keyword": "Redis", "weight": 0.6, "updated_at": "2024-06-09"},
{"keyword": "MySQL", "weight": 0.3, "updated_at": "2024-06-01"}
],
"frequent_topics": ["故障排查", "性能优化", "容量规划"],
"expertise_level": "高级"
},
"updated_at": "2024-06-10T10:30:00Z"
}
画像更新策略:
python
def update_user_profile(user_id: str, query: str, clicked_chunks: list):
"""根据用户行为更新画像"""
profile = get_user_profile(user_id)
# 从 query 中提取关键词
keywords = extract_keywords(query) # 可用千问小模型做 NER
for keyword in keywords:
existing = find_keyword_in_profile(profile, keyword)
if existing:
# 已有关键词:增强权重(衰减式增长)
existing["weight"] = min(1.0, existing["weight"] + 0.1)
existing["updated_at"] = now()
else:
# 新关键词:添加
profile["preferences"]["keywords"].append({
"keyword": keyword,
"weight": 0.3, # 初始权重
"updated_at": now()
})
# 权重衰减:长期未出现的关键词权重降低
for kw in profile["preferences"]["keywords"]:
days_since_update = (now() - parse(kw["updated_at"])).days
if days_since_update > 30:
kw["weight"] *= 0.95 # 每天衰减 5%
save_user_profile(user_id, profile)
运维场景举例:
用户 A 是 K8s 运维,经常问 K8s 相关问题,他的画像中 "K8s" 权重 0.9。当他问"如何排查网络问题"时,系统会倾向于召回 K8s 网络排查的文档(如 CNI、Service、Ingress 相关),而不是普通网络排查的文档。
更多长期记忆场景:
场景 2:DBA 用户 vs 开发用户
| 用户 | 画像关键词 | 问"数据库慢怎么排查"时的偏好 |
|---|---|---|
| DBA 张三 | MySQL:0.95, Redis:0.8, 索引:0.9, 复制延迟:0.7 | 偏好:索引优化、慢查询分析、主从延迟排查 |
| 开发李四 | Java:0.8, Spring:0.7, 连接池:0.6, SQL:0.5 | 偏好:连接池配置、ORM 查询优化、SQL 写法 |
同一个问题,两个用户看到的回答侧重点不同------DBA 看到的是数据库层面的排查,开发看到的是应用层面的优化。
场景 3:画像的动态演变
用户王五刚开始负责 K8s 运维,画像中 K8s 权重只有 0.3。随着他越来越多地查询 K8s 相关问题,权重逐步提升:
Week 1: K8s:0.3, Nginx:0.8, MySQL:0.7
Week 2: K8s:0.5, Nginx:0.7, MySQL:0.6
Week 3: K8s:0.7, Nginx:0.6, MySQL:0.5
Week 4: K8s:0.85, Nginx:0.5, MySQL:0.4
同时,权重衰减机制确保了不再关注的领域权重会逐渐降低。如果王五转岗做数据库运维,一段时间不问 K8s 问题后,K8s 权重会自动衰减。
七、Query 改写
用户原始 query 往往是模糊的、口语化的、缺少关键信息的。Query 改写通过引入上下文和偏好,将模糊 query 转化为精准 query。
7.1 改写流程
原始 Query + 短期记忆(最近10轮) + 长期偏好(用户画像)
↓
千问小模型(Qwen2.5-7B)
↓
改写后的 Query
↓
向量化 → 混合检索
7.2 改写 Prompt 设计
python
REWRITE_PROMPT = """你是一个查询改写助手。请根据用户的对话历史和个人偏好,将用户的最新问题改写为一个更完整、更具体的检索查询。
要求:
1. 补全代词和省略信息(如"它"→具体指代的对象)
2. 结合用户偏好,在不改变原意的前提下补充可能相关的技术领域
3. 改写后的查询应该是一个自包含的、可以直接用于检索的句子
4. 不要添加用户未提及的不相关信息
5. 保持改写后的查询简洁,不超过 50 字
对话历史:
{conversation_history}
用户偏好关键词:{user_preferences}
用户最新问题:{original_query}
改写后的查询:"""
运维场景举例:
| 场景 | 原始 Query | 改写后 Query |
|---|---|---|
| 指代消解 | "它一直重启怎么办" | "K8s Pod CrashLoopBackOff 一直重启怎么排查" |
| 偏好增强 | "网络不通怎么查" | "K8s 集群 Pod 网络不通排查方法" |
| 多轮补全 | "还有其他方法吗" | "Nginx 502 Bad Gateway 除了检查上游服务还有其他排查方法" |
| 口语化转书面 | "Redis 挂了数据咋办" | "Redis 宕机后数据恢复与高可用方案" |
| 缩写展开 | "OOM 怎么查" | "K8s Pod OOMKilled 排查方法" |
| 错别字纠正 | "ngnix 配置错误" | "Nginx 配置错误排查" |
| 上下文补全 | "第三步报错了" | "MySQL 主从同步第三步(启动从库复制)报错排查" |
更多改写场景详解:
场景 1:多轮对话中的指代消解
对话历史:
用户: K8s 里有个 Pod 一直 CrashLoopBackOff
助手: [给出 CrashLoopBackOff 排查步骤]
用户: 查了日志是 OOMKilled
助手: [建议调大 memory limits]
用户: 调大了还是不行
原始 Query: "调大了还是不行"
改写后 Query: "K8s Pod OOMKilled 调大 memory limits 后仍然 OOM 的进一步排查方法"
改写后,检索系统能精确找到"调大内存后仍然 OOM"的相关文档,而不是泛泛的 OOM 排查文档。
场景 2:偏好增强的改写
DBA 用户的画像:{MySQL: 0.9, 主从复制: 0.85, 慢查询: 0.8}
原始 Query: "数据库同步延迟怎么处理"
改写后 Query: "MySQL 主从复制同步延迟排查与处理方法"
改写时加入了用户偏好"MySQL"和"主从复制",使得检索结果偏向 MySQL 主从同步的文档,而不是 PostgreSQL 或 Oracle 的同步文档。
场景 3:改写失败的边界情况
原始 Query: "K8s Pod 启动失败"
改写后 Query: "Kubernetes Pod 启动失败原因分析与排查步骤"
这个改写是合理的。但如果改写模型"发挥过度":
原始 Query: "K8s Pod 启动失败"
错误改写: "Kubernetes Pod CrashLoopBackOff ImagePullBackOff OOMKilled 启动失败全面排查指南"
改写后 query 包含了太多具体错误类型,导致检索结果偏向这三种特定错误,而忽略了其他启动失败的原因。这就是"过度改写"的问题------改写应该补全信息,而不是推测具体场景。
7.3 改写的边界
改写不是万能的。需要注意:
- 不要过度改写:如果原始 query 已经足够明确,改写可能引入噪音。可以在 prompt 中加一个判断------如果 query 已经清晰,直接返回原文。
- 保留原始 query 做备选:用改写 query 做主检索,同时用原始 query 做补充检索,合并结果。
- 改写结果需要可解释:用户应该能看到改写后的 query,理解系统为什么给出某个回答。
八、召回与精排
8.1 两阶段检索架构
混合检索(粗排) Reranker(精排)
向量检索 + 关键词检索 千问 Reranker 模型
↓ RRF 融合 ↓
Top-20 候选 Top-10 精排结果
↓ 意图权重调整 ↓
送入 LLM
为什么需要两阶段?粗排保证召回率(不漏),精排保证准确率(排对)。
8.2 Reranker 精排
混合检索的 RRF 排序已经不错了,但它本质上是基于检索分数的启发式融合,不能理解 query 和 chunk 之间的深层语义关系。Reranker 通过交叉编码(cross-encoder)对 query-chunk 对做深度语义匹配,显著提升排序质量。
python
from FlagEmbedding import FlagReranker
reranker = FlagReranker('BAAI/bge-reranker-large', use_fp16=True)
def rerank(query: str, chunks: list[dict], top_k: int = 10) -> list[dict]:
"""
对混合检索的结果做精排
"""
pairs = [[query, chunk["content"]] for chunk in chunks]
scores = reranker.compute_score(pairs)
# 按精排分数重新排序
scored_chunks = list(zip(chunks, scores))
scored_chunks.sort(key=lambda x: x[1], reverse=True)
return [
{**chunk, "rerank_score": score}
for chunk, score in scored_chunks[:top_k]
]
运维场景举例:
用户问"Redis 内存飙升怎么排查"。混合检索 Top-20 中可能包含:
- Redis 内存飙升排查步骤(高度相关)
- Redis 内存优化配置(相关但不是排查)
- Redis 和 Memcached 内存对比(语义相似但无关)
- 服务器内存飙升排查(关键词匹配但不是 Redis)
Reranker 能准确识别 1 和 2 是最相关的,3 和 4 应该排在后面。
更多 Reranker 精排示例:
示例 2:运维场景的精排对比
用户问"Nginx 502 排查步骤"。混合检索 Top-10 的 Reranker 精排前后对比:
| 排名 | 精排前(RRF 分数) | 精排后(Reranker 分数) |
|---|---|---|
| 1 | 《Nginx 故障指南-502 Bad Gateway》(0.032) | 《Nginx 故障指南-502 Bad Gateway》(0.92) |
| 2 | 《Nginx 配置参考-proxy 参数》(0.028) | 《Nginx 故障指南-502 排查步骤》(0.88) |
| 3 | 《Nginx 故障指南-502 排查步骤》(0.025) | 《运维手册-上游服务故障处理》(0.81) |
| 4 | 《Nginx 故障指南-504 Gateway Timeout》(0.023) | 《Nginx 配置参考-proxy 参数》(0.65) |
| 5 | 《运维手册-上游服务故障处理》(0.021) | 《Nginx 故障指南-504 Gateway Timeout》(0.42) |
精排前,"502 排查步骤"排在第 3,被"proxy 参数"压过------因为 proxy 参数文档关键词密度更高。精排后,Reranker 理解了"排查步骤"和"502"的语义关系,将排查相关文档提升到前 2 位。
示例 3:Reranker 对语义相似但意图不同的区分
用户问"Redis 连接超时怎么排查":
| Chunk 内容 | 向量相似度 | Reranker 分数 | 原因 |
|---|---|---|---|
| Redis 连接超时排查步骤 | 0.89 | 0.95 | 高度相关 |
| Redis 连接池配置说明 | 0.82 | 0.78 | 相关,但不是排查 |
| Redis 集群连接超时案例 | 0.85 | 0.88 | 高度相关(集群场景) |
| MySQL 连接超时排查 | 0.71 | 0.25 | 语义相似但技术栈不同 |
| Redis 性能优化指南 | 0.76 | 0.45 | 语义相关但不是排查 |
Reranker 大幅降低了"MySQL 连接超时"的分数(0.71→0.25),因为虽然"连接超时"语义相似,但技术栈不匹配。
8.3 Top-K 选择
为什么是 Top-10 而不是 Top-5 或 Top-20?
- Top-5:信息可能不够充分,特别是复杂问题需要多角度回答
- Top-10:在信息充分性和 LLM 上下文窗口之间取得平衡
- Top-20:噪音增加,LLM 可能被无关信息干扰
Top-10 是一个经验值,可以根据实际效果调整。如果 Reranker 质量高,Top-5 可能就够了;如果问题比较发散,Top-15 可能更好。
九、LLM 生成
9.1 Prompt 工程
将精排后的 Top-10 chunks 和改写后的 query 组装成 prompt,送入千问大模型生成回答。
python
GENERATION_PROMPT = """你是一个专业的企业知识库助手。请根据以下参考资料回答用户的问题。
要求:
1. 回答必须基于参考资料,不要编造信息
2. 如果参考资料不足以回答问题,明确告知用户
3. 引用来源时标注 [文档名-章节]
4. 回答要结构化,使用列表和标题组织内容
5. 如果参考资料中有图片链接,在相关内容后附上图片链接
参考资料:
{context}
用户问题:{query}
回答:"""
9.2 上下文窗口管理
10 个 chunks × 平均 512 tokens = 约 5120 tokens 的上下文,加上 prompt 模板和用户 query,在千问大模型的上下文窗口内完全可控。但如果 chunk 较大,需要做截断处理:
python
def build_context(chunks: list[dict], max_tokens: int = 6000) -> str:
"""组装上下文,控制总 token 数"""
context_parts = []
total_tokens = 0
for i, chunk in enumerate(chunks, 1):
chunk_text = f"[{chunk['metadata']['doc_title']}-{chunk['metadata'].get('h2', '概述')}]\n{chunk['content']}"
chunk_tokens = estimate_tokens(chunk_text)
if total_tokens + chunk_tokens > max_tokens:
# 截断当前 chunk
remaining = max_tokens - total_tokens
chunk_text = truncate_by_tokens(chunk_text, remaining)
context_parts.append(chunk_text)
break
context_parts.append(chunk_text)
total_tokens += chunk_tokens
return "\n\n---\n\n".join(context_parts)
运维场景举例:
用户问"Redis 集群脑裂怎么处理",精排 Top-10 中有 3 个 chunk 来自《Redis 运维手册》的"集群脑裂"章节,2 个来自《Redis 故障案例库》的脑裂案例,5 个是其他 Redis 集群相关内容。LLM 会重点基于前 5 个 chunk 生成回答,并引用来源:
根据《Redis 运维手册-集群脑裂》和《Redis 故障案例库》,Redis 集群脑裂的处理步骤如下:
1. **确认脑裂状态**:检查集群节点状态 `redis-cli cluster info`,
如果 cluster_state 为 fail,则可能存在脑裂 [Redis 运维手册-集群脑裂]
2. **隔离少数派节点**:将数据落后的少数派节点下线,
避免数据继续分歧 [Redis 故障案例库-脑裂案例]
3. **数据修复**:使用 `redis-cli --cluster fix` 修复槽位分配 [Redis 运维手册-集群脑裂]
十、反馈系统与意图学习
这是整个 RAG 系统中最具差异化的部分。大多数 RAG 系统止步于"检索-生成",而反馈系统让 RAG 从"一次性服务"进化为"持续学习系统"。
10.1 反馈数据模型
sql
-- 用户反馈表
CREATE TABLE user_feedback (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id VARCHAR(64) NOT NULL,
conversation_id VARCHAR(64) NOT NULL,
query TEXT NOT NULL, -- 用户原始问题
rewritten_query TEXT, -- 改写后的问题
answer TEXT NOT NULL, -- 系统回答
feedback_type ENUM('thumbs_up', 'thumbs_down', 'neutral') DEFAULT 'neutral',
dwell_time INT, -- 用户停留时间(秒)
has_follow_up BOOLEAN DEFAULT FALSE, -- 是否有追问
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 召回 chunk 关联表
CREATE TABLE feedback_chunk (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
feedback_id BIGINT NOT NULL REFERENCES user_feedback(id),
chunk_id VARCHAR(64) NOT NULL,
recall_rank INT NOT NULL, -- 粗排排名
rerank_rank INT NOT NULL, -- 精排排名
rerank_score FLOAT, -- 精排分数
is_used BOOLEAN DEFAULT FALSE, -- 是否被 LLM 采用(可通过对齐分析判断)
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 意图表
CREATE TABLE intend (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
intend_name VARCHAR(255) NOT NULL, -- 意图名称
intend_query TEXT NOT NULL, -- 代表性问题
query_count INT DEFAULT 0, -- 归类问题数
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
-- 意图-chunk 权重表
CREATE TABLE intend_chunk_weight (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
intend_id BIGINT NOT NULL REFERENCES intend(id),
chunk_id VARCHAR(64) NOT NULL,
weight FLOAT DEFAULT 0.5, -- 0-1,越高表示该 chunk 对该意图越重要
feedback_score FLOAT DEFAULT 0, -- 基于反馈的累计得分
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
10.2 反馈信号采集
反馈信号来自多个维度,不仅仅依赖用户主动点赞/点踩:
| 信号类型 | 采集方式 | 含义 |
|---|---|---|
| 显式反馈 | 点赞/点踩按钮 | 最直接的质量信号 |
| 停留时间 | 前端埋点,记录用户阅读回答的时间 | 短时间离开 = 回答不相关或无用 |
| 追问行为 | 检测对话是否在同一话题继续 | 追问 = 回答不完整或不够精准 |
| 复制行为 | 前端埋点,记录用户是否复制了回答 | 复制 = 回答有实用价值 |
| 重新提问 | 同一对话中换一种方式问同一问题 | 重新提问 = 上次回答不满意 |
更多反馈信号场景举例:
场景 1:运维排班中的反馈解读
| 用户行为 | 信号 | 解读 | 反馈分 |
|---|---|---|---|
| 问"Redis 慢查询怎么排查",阅读回答 45 秒,复制了 EXPLAIN 命令 | 停留长 + 复制 | 回答有实用价值 | +0.4 |
| 问"K8s 部署失败",3 秒后追问"不是这个,我说的是 Helm 部署" | 停留短 + 追问 | 召回了错误的文档类型 | -0.3 |
| 问"Nginx 配置优化",点赞后关闭对话 | 点赞 | 回答满意 | +0.5 |
| 问"MySQL 主从延迟",点踩,然后换一种方式问"数据库同步慢" | 点踩 + 重新提问 | 回答不满意且未解决 | -0.6 |
| 问"JVM 调优参数",阅读 20 秒,没有追问也没有点赞 | 中性 | 回答一般,可接受 | 0.0 |
场景 2:反馈信号的时间维度
反馈不是静态的,同一个 chunk 在不同场景下反馈可能不同:
Chunk: 《Redis 内存优化-配置 maxmemory-policy》
场景 A: 用户问"Redis 内存满了怎么办" → 点赞 → chunk 对"内存满"意图权重 +0.5
场景 B: 用户问"Redis 数据丢失了" → 点踩 → chunk 对"数据丢失"意图权重 -0.3
同一个 chunk 在"内存优化"意图下是高权重(0.9),在"数据丢失"意图下是低权重(0.2)。这就是为什么需要意图-chunk 权重表,而不是简单的 chunk 全局评分。
综合评分公式:
python
def calculate_feedback_score(feedback: dict) -> float:
"""
综合多维反馈信号计算评分
范围: [-1, 1],正值表示正向反馈,负值表示负向反馈
"""
score = 0.0
# 显式反馈(权重最高)
if feedback["feedback_type"] == "thumbs_up":
score += 0.5
elif feedback["feedback_type"] == "thumbs_down":
score -= 0.5
# 停留时间
dwell_time = feedback.get("dwell_time", 0)
if dwell_time > 30: # 阅读超过 30 秒
score += 0.2
elif dwell_time < 5: # 5 秒内离开
score -= 0.2
# 追问行为
if feedback.get("has_follow_up"):
score -= 0.1 # 追问表示回答不够完整
# 复制行为
if feedback.get("has_copy"):
score += 0.2 # 复制表示回答有实用价值
return max(-1.0, min(1.0, score))
10.3 意图聚类与权重学习
这是最核心的闭环机制。通过离线批处理,将用户的相似问题聚类为意图,学习每个意图下各 chunk 的权重,最终在检索和排序阶段利用这些权重提升效果。
离线处理流程:
定时任务(如每天凌晨)
↓
1. 拉取 user_feedback 表中所有问题
↓
2. 对问题做 Embedding,计算相似度矩阵
↓
3. 相似度 > 85% 的问题归为同一意图
↓
4. 更新 intend 表(新增/合并意图)
↓
5. 计算每个意图下各 chunk 的权重
↓
6. 更新 intend_chunk_weight 表
python
def intent_clustering(feedbacks: list[dict], similarity_threshold: float = 0.85):
"""
意图聚类:将相似问题归为同一意图
"""
queries = [f["query"] for f in feedbacks]
embeddings = model.encode(queries)
# 计算相似度矩阵
similarity_matrix = cosine_similarity(embeddings)
# 贪心聚类
clusters = []
assigned = set()
for i in range(len(queries)):
if i in assigned:
continue
cluster = [i]
assigned.add(i)
for j in range(i + 1, len(queries)):
if j not in assigned and similarity_matrix[i][j] >= similarity_threshold:
cluster.append(j)
assigned.add(j)
clusters.append(cluster)
# 为每个聚类创建/更新意图
for cluster in clusters:
cluster_feedbacks = [feedbacks[i] for i in cluster]
representative_query = select_representative(cluster_feedbacks)
intend_id = find_or_create_intend(
name=generate_intend_name(representative_query),
representative_query=representative_query,
query_count=len(cluster)
)
# 更新 chunk 权重
update_chunk_weights(intend_id, cluster_feedbacks)
def update_chunk_weights(intend_id: int, feedbacks: list[dict]):
"""
更新意图下各 chunk 的权重
核心思路:正向反馈的 chunk 权重提升,负向反馈的 chunk 权重降低
"""
# 收集该意图下所有被召回的 chunk 及其反馈
chunk_scores = {}
for feedback in feedbacks:
feedback_score = calculate_feedback_score(feedback)
chunks = get_feedback_chunks(feedback["id"])
for chunk in chunks:
cid = chunk["chunk_id"]
if cid not in chunk_scores:
chunk_scores[cid] = {"positive": 0, "negative": 0, "total": 0}
chunk_scores[cid]["total"] += 1
if feedback_score > 0:
chunk_scores[cid]["positive"] += feedback_score
elif feedback_score < 0:
chunk_scores[cid]["negative"] += abs(feedback_score)
# 计算最终权重
for chunk_id, scores in chunk_scores.items():
# 权重 = 正向得分占比,衰减处理
if scores["total"] > 0:
weight = (scores["positive"] - scores["negative"]) / scores["total"]
weight = max(0.1, min(1.0, 0.5 + weight * 0.5)) # 归一化到 [0.1, 1.0]
else:
weight = 0.5 # 默认权重
upsert_intend_chunk_weight(intend_id, chunk_id, weight)
10.4 意图权重在检索中的应用
意图权重在精排之后、送入 LLM 之前应用,作为最后一道排序调整:
python
def apply_intent_boost(chunks: list[dict], user_id: str, query: str) -> list[dict]:
"""
在精排结果上应用意图权重调整
"""
# 1. 判断当前 query 是否命中已知意图
query_embedding = embed_query(query)
intends = get_all_intends()
matched_intend = None
max_similarity = 0.0
for intend in intends:
intend_embedding = embed_query(intend["intend_query"])
similarity = cosine_similarity([query_embedding], [intend_embedding])[0][0]
if similarity > 0.85 and similarity > max_similarity:
max_similarity = similarity
matched_intend = intend
if not matched_intend:
return chunks # 未命中意图,返回原始排序
# 2. 获取意图下的 chunk 权重
chunk_weights = get_intend_chunk_weights(matched_intend["id"])
weight_map = {w["chunk_id"]: w["weight"] for w in chunk_weights}
# 3. 调整排序分数
for chunk in chunks:
cid = chunk["chunk_id"]
if cid in weight_map:
# 加权:原分数 * (1 + 意图权重偏移)
intent_weight = weight_map[cid]
boost = 1.0 + (intent_weight - 0.5) # 0.5 是中性值,偏移范围 [-0.4, 0.5]
chunk["final_score"] = chunk["rerank_score"] * boost
else:
chunk["final_score"] = chunk["rerank_score"]
# 4. 重新排序
chunks.sort(key=lambda x: x["final_score"], reverse=True)
return chunks
运维场景举例:
经过一段时间的运行,系统学习到以下意图:
| 意图 | 代表性问题 | 高权重 Chunk |
|---|---|---|
| K8s Pod 重启排查 | "Pod 一直重启怎么办" | 《K8s 故障手册-CrashLoopBackOff》权重 0.9 |
| Redis 内存问题 | "Redis 内存飙升" | 《Redis 运维手册-内存优化》权重 0.85 |
| Nginx 502 排查 | "Nginx 502 怎么排查" | 《Nginx 故障指南-502 Bad Gateway》权重 0.95 |
当新用户问"Pod 不停重启怎么处理"时,即使 Reranker 给《K8s 故障手册-CrashLoopBackOff》的分数不是最高,意图权重也会把它提升到第一位,因为历史反馈证明这个 chunk 对"Pod 重启排查"意图最有用。
更多意图学习场景举例:
场景 1:意图聚类过程详解
假设过去一周收集到以下用户问题:
1. "Pod 一直重启怎么办" → 点赞,召回 chunk A
2. "K8s Pod 不停重启" → 点赞,召回 chunk A, B
3. "Pod CrashLoopBackOff 排查" → 点赞,召回 chunk A
4. "容器反复重启" → 点踩,召回 chunk C(错误召回)
5. "Pod 启动失败怎么查" → 追问,召回 chunk D
6. "Redis 连接超时" → 点赞,召回 chunk E, F
7. "Redis 连接池耗尽怎么处理" → 点赞,召回 chunk F
8. "Redis 超时排查" → 点赞,召回 chunk E
Embedding 相似度计算后:
- 问题 1-5 之间的相似度 > 0.85 → 归为意图"Pod 重启排查"
- 问题 6-8 之间的相似度 > 0.85 → 归为意图"Redis 连接超时"
意图权重学习结果:
意图:Pod 重启排查
| Chunk | 正向反馈 | 负向反馈 | 权重 |
|---|---|---|---|
| A: 《K8s 故障手册-CrashLoopBackOff》 | 3 次点赞 | 0 | 0.95 |
| B: 《K8s 故障手册-Pod 生命周期》 | 1 次点赞 | 0 | 0.7 |
| C: 《Docker 故障-容器重启》 | 0 | 1 次点踩 | 0.15 |
| D: 《K8s 部署-Pod 启动配置》 | 0 | 1 次追问 | 0.3 |
意图:Redis 连接超时
| Chunk | 正向反馈 | 负向反馈 | 权重 |
|---|---|---|---|
| E: 《Redis 运维手册-连接超时》 | 2 次点赞 | 0 | 0.9 |
| F: 《Redis 运维手册-连接池配置》 | 2 次点赞 | 0 | 0.85 |
场景 2:意图权重如何改变排序
用户问"Pod 反复重启怎么处理",Reranker 精排结果:
| 排名 | Chunk | Reranker 分数 |
|---|---|---|
| 1 | B: Pod 生命周期 | 0.82 |
| 2 | A: CrashLoopBackOff | 0.81 |
| 3 | D: Pod 启动配置 | 0.75 |
| 4 | C: Docker 容器重启 | 0.72 |
命中意图"Pod 重启排查"后,应用意图权重:
| 排名 | Chunk | Reranker 分数 | 意图权重 | 最终分数 |
|---|---|---|---|---|
| 1 | A: CrashLoopBackOff | 0.81 | 0.95→boost 1.45 | 0.81×1.45=1.17 |
| 2 | B: Pod 生命周期 | 0.82 | 0.7→boost 1.2 | 0.82×1.2=0.98 |
| 3 | D: Pod 启动配置 | 0.75 | 0.3→boost 0.8 | 0.75×0.8=0.60 |
| 4 | C: Docker 容器重启 | 0.72 | 0.15→boost 0.65 | 0.72×0.65=0.47 |
意图权重调整后,chunk A 从第 2 名升到第 1 名(历史反馈证明它最有用),chunk C 从第 4 名降到更后面(历史反馈表明它对这个意图不相关)。
场景 3:意图的合并与演化
随着时间推移,相似意图会被合并:
Week 1: 意图"Pod 重启排查"(5 个问题)
Week 2: 意图"Pod 崩溃循环"(3 个问题)
Week 3: 离线任务发现两个意图的代表问题相似度 > 0.9
→ 合并为意图"Pod 异常重启排查"(8 个问题)
→ 重新计算 chunk 权重(更多反馈数据,权重更准确)
十一、完整数据流总览
把所有模块串起来,一个完整的用户请求流程如下:
示例 1:Redis 集群脑裂排查
1. 用户提问:"Redis 集群脑裂怎么处理"
│
2. 加载短期记忆:最近 10 轮对话(Redis 对话上下文)
│
3. 加载长期偏好:用户画像(K8s: 0.3, Redis: 0.8, MySQL: 0.1)
│
4. Query 改写:
原始 → "Redis 集群脑裂怎么处理"
改写 → "Redis Cluster 脑裂问题排查与恢复方法"
│
5. 改写后 Query 向量化 → BGE-large-zh → 1024 维向量
│
6. 混合检索(OpenSearch):
- 向量检索:Top-20 语义相似 chunks
- 关键词检索:Top-20 关键词匹配 chunks
- RRF 融合 → Top-20 候选
- 权限过滤:只保留用户可见的 chunks
│
7. 意图权重调整:
- 匹配意图"Redis 集群脑裂"
- 高权重 chunks 提升排名
│
8. Reranker 精排(千问 Reranker):
- Top-20 → 精排 → Top-10
│
9. LLM 生成(千问大模型):
- Top-10 chunks + 改写后 query → 生成回答
│
10. 返回回答 + 采集反馈信号
│
11. 离线:意图聚类 + 权重学习 → 更新意图表
示例 2:K8s Pod OOM 排查(带多轮对话上下文)
对话历史:
用户: K8s 里有个服务老是 OOM
助手: [给出 OOMKilled 排查步骤]
用户: 调大了 memory limits 到 4Gi 还是 OOM
1. 用户提问:"还是 OOM,还有什么原因"
│
2. 加载短期记忆:
- "K8s 里有个服务老是 OOM"
- [助手回答:OOMKilled 排查步骤]
- "调大了 memory limits 到 4Gi 还是 OOM"
│
3. 加载长期偏好:用户画像(K8s: 0.9, Java: 0.7)
│
4. Query 改写:
原始 → "还是 OOM,还有什么原因"
改写 → "K8s Pod OOMKilled 调大 memory limits 后仍然 OOM 的其他原因排查"
(补全了:K8s Pod 上下文 + OOMKilled 具体错误 + 已尝试的方案)
│
5. 向量化 + 混合检索 → Top-20 候选
│
6. 意图权重调整:
- 匹配意图"Pod OOM 排查"
- 提升"调大内存后仍 OOM"相关 chunk 的权重
│
7. Reranker 精排 → Top-10
- 精排后靠前的 chunk:Java 堆内存超限、sidecar 容器内存泄漏、
cgroup 内存限制与 JVM 堆内存不匹配等
│
8. LLM 生成:
"调大 memory limits 后仍然 OOM,可能的原因包括:
1. JVM 堆内存设置不合理:JVM 堆内存(-Xmx)可能远小于容器 memory limits,
但 JVM 的非堆内存(元空间、线程栈等)也会占用容器内存
2. Sidecar 容器内存泄漏:Istio sidecar 或日志采集 sidecar 可能占用大量内存
3. 内存限制与实际使用不匹配:4Gi limits 可能仍然不够
[Redis 运维手册-内存优化](图片链接)..."
示例 3:Nginx 配置问题(带反馈闭环)
1. 用户提问:"Nginx proxy_pass 配置报错"
│
2. Query 改写 → "Nginx proxy_pass 配置语法错误排查方法"
│
3. 混合检索 + 意图权重 + Reranker → Top-10
│
4. LLM 生成 → 返回排查步骤
│
5. 用户反馈:
- 点赞 ✓
- 停留时间:52 秒(仔细阅读)
- 复制了 nginx -t 检查命令 ✓
- 无追问 ✓
- 综合评分:+0.9(强正向反馈)
│
6. 离线处理(凌晨定时任务):
- "Nginx proxy_pass 配置报错" 与 "Nginx 反向代理配置错误" 相似度 0.89
- 归入意图"Nginx 代理配置问题"
- 被点赞的 chunk 权重提升至 0.95
- 下次有人问类似问题,这些高权重 chunk 会排在更前面
十二、关键设计决策回顾
最后,总结本文中的关键设计决策及其理由:
| 决策 | 选择 | 理由 |
|---|---|---|
| 图片处理 | VLM 提取描述 + 保留原图链接 | 兼顾检索可发现性和视觉可回溯性 |
| 表格处理 | 按大小分策略:小表保留、中表线性化、大表分行切片 | 保留表格语义的同时兼顾检索和切分 |
| 表格线性化 | 补充隐含语义信息(如"内存满"→maxmemory-policy) | 原文表格缺少隐含语义,线性化后语义检索大幅提升 |
| 文档格式 | 统一转 Markdown | 保留语义结构,切分有据可依 |
| 切分策略 | 按标题层级切分,512 tokens 目标 | 语义完整性优于固定长度切分 |
| Embedding | BGE-large-zh | 中文场景最优,私有化资源消耗低 |
| 向量存储 | OpenSearch | 一引擎支持向量+关键词+权限过滤 |
| 混合检索 | 向量 + 关键词 + RRF | 互补,召回率最高 |
| 短期记忆 | Redis 10 轮 | 解决对话连贯性,TTL 自动过期 |
| 长期记忆 | OpenSearch 用户画像 | 个性化检索偏好 |
| Query 改写 | 千问 2.5-7B | 补全上下文、消除歧义 |
| 精排 | Reranker 模型 | 深度语义匹配,提升排序质量 |
| 反馈系统 | 多维信号 + 意图聚类 | 闭环学习,持续优化 |
| 意图权重 | 精排后调整 | 避免干扰 Reranker,仅做微调 |
结语
构建企业级 RAG 系统不是搭积木------把各个组件拼起来就能跑。真正的挑战在于每个环节的技术决策和工程细节:图片描述要可检索、切分要保语义、Embedding 要分清 query 和 doc、混合检索要做 RRF、反馈要闭环学习。
本文描述的系统已经在多个运维场景中验证了其有效性:故障排查的召回准确率从最初的 60% 提升到 85%+,意图学习闭环运行两周后,常见问题的回答满意度提升了 15%。
运维场景的实际效果数据:
| 指标 | 基线(纯向量检索) | 加入混合检索 | 加入 Reranker | 加入意图学习 |
|---|---|---|---|---|
| 召回率@10 | 62% | 78% | 82% | 87% |
| 准确率@3 | 55% | 70% | 80% | 88% |
| 用户满意度 | 3.2/5 | 3.8/5 | 4.1/5 | 4.4/5 |
| 首次解决率 | 45% | 58% | 67% | 75% |
典型的运维问题检索效果对比:
| 问题 | 纯向量检索 Top-3 | 混合+Reranker+意图 Top-3 |
|---|---|---|
| "Pod OOMKilled 怎么排查" | 1个相关 | 3个全相关 |
| "Nginx 502 排查步骤" | 2个相关 | 3个全相关 |
| "Redis 内存飙升" | 1个相关(另2个是MySQL内存) | 3个全相关 |
| "K8s 网络不通" | 2个相关(1个是普通网络) | 3个全K8s网络相关 |
| "MySQL 主从延迟" | 2个相关 | 3个全相关 |