企业级 RAG 系统实战详解

一、整体架构设计

一个完整的企业级 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"![{alt_text}]({image_path})"
        replacement = f"[图片内容:{description}]({image_url})"
        markdown_content = markdown_content.replace(original_ref, replacement)

    return markdown_content

运维场景举例

假设你有一篇运维知识库文档《K8s 集群故障排查手册》,其中有一张 Pod CrashLoopBackOff 的排查流程图:

原始 Markdown:

markdown 复制代码
![排查流程](images/crashloop-flow.png)

处理后:

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 复制代码
![告警配置](images/alert-config.png)

处理后:

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 复制代码
![系统架构](images/architecture.png)

处理后:

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.nameprod-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

推荐使用 mammothdocx2md。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 本质是排版描述,不是结构化文档。推荐方案:

  1. 文本型 PDF :使用 markerpdfplumber,保留标题层级和表格结构
  2. 扫描型 PDF:先 OCR(如 PaddleOCR),再用上述工具处理
  3. 混合型 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 1502 Bad Gateway 下的所有内容(常见原因 + 排查步骤)
  • Chunk 2504 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=16ef_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 中可能包含:

  1. Redis 内存飙升排查步骤(高度相关)
  2. Redis 内存优化配置(相关但不是排查)
  3. Redis 和 Memcached 内存对比(语义相似但无关)
  4. 服务器内存飙升排查(关键词匹配但不是 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个全相关
相关推荐
Stick_ZYZ1 小时前
A2A:让 Agent 从单兵作战走向团队协作
java·开发语言·网络·人工智能·python·ai
BizViewStudio1 小时前
2026 年 GEO 成为企业线上流量增长核心风口|2026 品牌 GEO 运营指南,6 家全链路优化服务商解析
运维·网络·人工智能·microsoft·ai
JaydenAI1 小时前
[MAF预定义Agent中间件-05]ToolApprovalAgent-摆脱重复审批的烦恼
ai·c#·agent·maf·agent中间件
码农阿强2 小时前
Claude-Fable-5 技术详解 + 基于 startapi.top 接口实战调用(附多语言代码示例)
人工智能·gpt·ai·aigc·ai编程
海棠AI实验室2 小时前
AI 时代文献综述:从检索到成稿的 RAG 五步法
windows·算法·自动化·llm·rag
todoitbo2 小时前
把 GitNexus 接进 Codex:安装、索引、Web UI 和项目分析实操
人工智能·ai·codex·claude code·gitnexus
学废了wuwu2 小时前
minGPT学习路径
ai
财经资讯数据_灵砚智能2 小时前
基于全球经济类多源新闻的NLP情感分析与数据可视化(夜间-次晨)2026年6月9日
人工智能·python·ai·信息可视化·自然语言处理·ai编程·灵砚智能
钱多多_qdd2 小时前
claude code(九):【Claude Code官方最佳实践7️⃣】:通过多 Claude 工作流程提升水平
ai·claude