## 一、技术选型的纠结与最终决策

### 1.1 向量数据库的选择

这是第一个选择题。我评估了几个主流选项:

| 数据库 | 优势 | 劣势 | 我的判断 |
|--------|------|------|----------|
| Milvus | 功能最全、开源、社区活跃 | 部署运维较重,需要K8s | 适合大规模生产 |
| Qdrant | Rust写的,性能好,部署简单 | 生态相对年轻 | 中小规模首选 |
| PGVector | 基于PostgreSQL,运维统一 | 索引重建慢,大数据量吃力 | 小项目或已有PG栈 |
| Elasticsearch | 团队熟悉,支持全文+向量混合检索 | 向量性能不如专用库 | 我们最终选了它 |

最终我选了Elasticsearch 8.11以上版本。原因很实际:团队对ES太熟了,不需要额外学习成本,而且我们除了语义检索,还需要大量基于关键词的精确匹配(比如搜索设备编号"MC-2024-0087"这种,向量检索一塌糊涂)。ES天然支持混合检索,这个优势在后面会展开说。

### 1.2 Embedding模型的选择

中文Embedding模型我测试了四款:text2vec-large-chinese、m3e-base、bge-large-zh-v1.5、以及OpenAI的text-embedding-3-small。

测试方法很简单:从客户文档里抽了200个问答对,计算Recall@10。结果:

- bge-large-zh-v1.5:0.87
- m3e-base:0.84
- text2vec:0.79
- OpenAI:0.88(但延迟高、有数据出境问题)

最终选bge-large-zh-v1.5,理由:中文效果第一梯队,可本地部署,显存占用约2.5GB,我们的单卡A10能跑。

## 二、数据处理:RAG效果的命门

很多教程把RAG的重点放在检索和生成上,我在实际项目中得出的结论恰好相反:**数据处理占了成功率的70%**。以下是我被现实教训后总结的清洗pipeline。

### 2.1 文档解析的噩梦

客户的文档格式五花八门:PDF、Word、Excel、PPT、扫描图片。PDF里还有大量表格和流程图。

踩坑1:PyPDF2解析带表格的PDF,出来的文字顺序完全错乱。解决方案是换用PDFPlumber,它能保留坐标信息,按阅读顺序重组文本。

踩坑2:扫描版PDF是图片,需要OCR。试了Tesseract,中文识别率惨不忍睹。最终上了PaddleOCR,配合版面分析模型,表格和正文分开处理。

踩坑3:Word文档里嵌的Excel对象,python-docx读不到。解决方案是先用Aspose或Unstructured库做深度解析。

最终的文档解析架构:

```
原始文档 → 格式识别 → 
  ├─ PDF(文本) → PDFPlumber
  ├─ PDF(扫描) → PaddleOCR + 版面分析
  ├─ Word → python-docx + 解压内嵌对象
  ├─ Excel → pandas读取每个sheet
  └─ PPT → python-pptx
        ↓
  统一输出为Markdown格式(保留标题层级和表格)
```

为什么统一转Markdown?因为Markdown的标题层级可以被切分器利用,表格语法能被LLM更好理解。

### 2.2 智能切分:最被低估的技术点

最初的方案很天真:按固定长度512字符切分,加20字符重叠。结果惨不忍睹——一段技术参数被从中切断,上半段问"这个设备的功率是多少"召回不到对应的下半段。

我最终采用的方案是**语义切分 + 父子文档结构**。

**语义切分逻辑**:
1. 先用标题层级(# ## ###)作为天然分割边界
2. 对于没有标题的正文,用句子边界(句号、问号、感叹号)+ 语义相似度来判断段落边界
3. 每个chunk最小200字、最大800字

**父子文档结构**是我认为最实用的技巧:

- **父文档**:完整的段落或章节(800-1200字),用于后续LLM生成答案的上下文
- **子文档**:将父文档拆成小块(200-300字),用于向量检索

检索时匹配子文档,但返回对应的父文档给LLM。这样既保证了检索精度(小粒度匹配),又保证了上下文完整性(大粒度输入)。

代码片段:

```python
class DocumentChunker:
    def __init__(self, min_chunk_size=200, max_chunk_size=800):
        self.min_size = min_chunk_size
        self.max_size = max_chunk_size
    
    def split_by_semantic(self, text: str, headers: List[str]) -> List[Dict]:
        # 先按标题切
        sections = self.split_by_headers(text, headers)
        chunks = []
        for section in sections:
            if len(section) <= self.max_size:
                chunks.append(self.create_chunk(section, is_parent=True))
            else:
                # 长文本按句子进一步切分
                sub_chunks = self.split_by_sentences(section, self.max_size)
                # 父chunk保留完整段落
                parent = self.create_chunk(section, is_parent=True)
                chunks.append(parent)
                # 子chunk用于检索,关联到父chunk ID
                for sub in sub_chunks:
                    child = self.create_chunk(sub, is_parent=False, parent_id=parent.id)
                    chunks.append(child)
        return chunks
```

## 三、检索策略:从单路到多路融合

初期只用向量检索,效果不行。原因是大量用户问的是"XX设备的维护流程是什么",这种问题里的"XX设备"是专有名词,向量检索无法精确匹配。

### 3.1 最终的混合检索架构

```
用户Query → 
    ├─ 向量检索(bge embedding) → 召回top20
    ├─ 关键词检索(ES的BM25) → 召回top20
    ├─ 重排序(Cross-Encoder) → 合并排序取top5
    └─ 输出给LLM
```

**重排序是关键**。向量检索和BM25各自的top20有重叠但不完全一样。用Cross-Encoder模型对合并后的候选集逐一计算与Query的相关性分数,重新排序。

这里有个细节:Cross-Encoder比Bi-Encoder(即双塔向量模型)慢得多,因为需要把Query和每个Document拼接后过模型。我的优化是只对合并后的最多30个候选做rerank,而不是对全库。

Cross-Encoder我用的是bge-reranker-large,效果明显:Hit@5从0.78提升到0.89。

### 3.2 Query改写:解决用户问法不规范的问题

一线员工提问极不规范,典型例子:
- "那个机器坏了咋整"(实际想问某型号数控机床的报警代码处理方法)
- "上次那个文件"(指代不明)

我加了一层Query改写模块,用一个小参数模型(Qwen2-7B)在后台做两件事:
1. **指代消解**:结合会话历史,把"那个机器"替换成具体型号
2. **问题补全**:把口语化问题改写为规范的检索式问句

实测Query改写后,检索Hit@5提升了约12个百分点。

## 四、生成环节:让LLM学会"我不知道"

### 4.1 Prompt工程的核心教训

一开始用的prompt很简单:"基于以下参考资料回答用户问题"。结果模型在各种"编造",尤其是当参考资料不包含答案时,它会强行凑一个似是而非的回答。

最后的prompt结构我迭代了十几版,稳定下来的核心框架:

```
你是一个{domain}领域的技术专家。
你的任务是根据【参考资料】回答用户问题。

【参考资料】:
{retrieved_chunks}

【用户问题】:
{query}

【回答要求】:
1. 如果参考资料足以回答问题,请给出准确、简洁的回答,并在回答末尾引用来源(标注【参考资料X】)
2. 如果参考资料部分相关但不完整,请说明"根据现有资料,可以确认以下部分...,但关于...的信息未找到"
3. 如果参考资料完全不相关或无有效信息,请直接回答"抱歉,现有知识库中未找到相关信息",**绝对不要编造**
4. 回答时使用条理化的格式,分点说明

【回答】:
```

关键在第3条:明确允许模型说"不知道"。这大幅降低了幻觉率。

### 4.2 对话记忆的取舍

产品经理要求支持多轮对话。初期方案是把完整历史对话都塞进上下文,结果Token消耗暴涨,且随着对话轮次增加,检索的query会越来越偏离原始问题。

我的最终方案:
- **对话历史只保留最近3轮**,超出部分压缩为摘要
- **每轮独立检索**:即使用户说"继续",系统也会把上一轮的上下文拼接到当前query后再去检索,而不是复用之前的检索结果
- **意图判断**:对用户query做轻量级意图分类,如果是"新话题"则清空对话记忆

## 五、评估体系:没有度量就没有优化

我建立了一套三层评估体系:

### 5.1 离线评估(开发阶段)

构建了500个问答对的测试集,每个问答对包含:问题、标准答案、参考答案文档ID列表。

核心指标:
- **Hit@5**:正确文档是否在召回top5中 → 目标 >0.9
- **MRR**:第一个正确文档的排序位置 → 目标 >0.85
- **AnswerCorrectness**:用GPT-4作为裁判,比对系统回答和标准答案的语义一致性 → 目标 >0.85

### 5.2 在线监控(生产阶段)

上线后必须监控:
- **平均响应延迟**:目标 < 3秒,超过则触发告警
- **用户反馈**:每个回答下面放"有用/无用"按钮,负反馈率 > 15% 则触发人工review
- **空回答率**:模型说"不知道"的比例,如果突然下降,说明幻觉可能上升

### 5.3 持续迭代

每月从生产日志中采样负反馈案例,人工标注后加入测试集,重新评估各模块效果,针对性优化。前三个月每月Hit@5提升2-3个百分点,之后趋缓。

## 六、算力成本和部署方案

最终生产环境配置:

| 组件 | 规格 | 成本/月 |
|------|------|---------|
| ES集群 | 3节点,每节点16核32G,SSD 2TB | 约4000元 |
| Embedding服务 | A10 24G单卡 | 约3000元 |
| Reranker服务 | 共用A10 | - |
| LLM服务 | 混元API(也可用Qwen2-72B本地部署) | 按Token计费,约0.02元/次 |
| 总成本 | | 约10000元/月 |

对于3000人企业的内部知识库,这个成本在可接受范围。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐