本篇对应代码:
app/rag/loader.py、embedding.py、vectorstore.py、retriever.py
RAG 是整套系统里最容易被低估的一环。 很多人以为它就是"切分 + embedding + top-k",但真正决定效果的是那些细节。
一、先看整体链路
bash
Markdown 文档
│ ① 结构优先切分(按标题 → 递归按句 → 保留重叠)
▼
Chunks(带 title / heading / source 元数据)
│ ② Embedding(openai 语义向量 / hashing 本地向量)
▼
向量库(memory / pgvector / chroma)
│
用户提问 ──┬─▶ ③ 向量召回 top20 ──┐
└─▶ ③ BM25 召回 top20 ─┴─▶ ④ RRF 融合 ─▶ ⑤ 重排 ─▶ ⑥ 阈值兜底 ─▶ top5
│
▼
⑦ 拼上下文(带 [编号])──▶ LLM
│
▼
⑧ 答案带引用 ──▶ 前端可展开原文
二、① 切分:结构优先,而不是按字数硬切
python
def split_markdown(doc, chunk_size=480, overlap=80):
# 1) 按 Markdown 标题(#/##/###)切块,记录标题路径
# 2) 超长块再递归切:\n\n → \n → 。 → ;→ ,
# 3) 相邻块保留 overlap
为什么这么做:
- 按字数硬切会把"定义"和"例外"拆开 → 模型只看到半句 → 答错
- 标题路径(
成本管理制度 > 预算与超支管控)有两个用途:重排加权 和 答案引用展示 - 递归切分保证了"优先在自然边界断开"
参数:chunk_size=480, overlap=80。 实测 300~600 区别不大,制度类文档段落短,480 能装下完整条款。
三、② Embedding:两套实现,一键切换
| 实现 | 适用 | 说明 |
|---|---|---|
OpenAIEmbedder |
生产 | text-embedding-3-small / 通义 v3,1024 维 |
HashingEmbedder |
离线/演示 | 零依赖,纯本地计算 |
HashingEmbedder 的原理(面试可以讲):
python
# 中文:单字 + 相邻双字(bigram);英文:按词
toks = tokenize(text)
# sublinear tf:1 + log(tf),避免长文本权重爆炸
w = 1.0 + math.log(count)
# 有符号哈希投影(hashing trick)
h = md5(token); idx = h % dim; sign = ±1
vec[idx] += sign * w
# L2 归一化 → 点积即余弦
质量不如语义向量,但全链路是真的 :切分、入库、混合召回、RRF、重排、阈值全部执行。 (这是"无 Key 也能演示"的关键一环)
四、③ 混合召回:为什么不能只用向量
这是我实测最有感的一点:
- 用户问 "P0 缺陷多久修复" ------
P0这种编号在语义向量里几乎没有区分度 - 用户问 "超支 20%" ------ 数字是关键词的强项、向量的弱项
- 用户问 "太湖网关 鉴权" ------ 专有名词向量容易漂移
所以双路召回:
python
vec_hits = store.search(qvec, candidate_k) # 向量语义召回
kw_scores = bm25.scores(query) # BM25 关键词召回
BM25 我手写了简化版(避免强依赖),公式:
scss
score = Σ idf(t) * (f * (k1+1)) / (f + k1 * (1 - b + b * dl/avgdl))
五、④ RRF 融合:为什么不用加权求和
python
# Reciprocal Rank Fusion
for rank, hit in enumerate(vec_hits): score[hit] += 1 / (60 + rank + 1)
for rank, hit in enumerate(kw_hits): score[hit] += 1 / (60 + rank + 1)
理由 :向量分是 01 的余弦,BM25 分可能是 0 20+, 加权求和需要调参且随语料漂移;RRF 只看排名,零参数、鲁棒性好。
六、⑤ 重排:成本与效果的平衡
python
class HeuristicReranker: # 默认,零调用成本
score = 0.6 * vector_score + 0.3 * 词覆盖度 + 0.1 * 标题命中
class LLMReranker: # RERANK_BACKEND=llm 时启用
# 让模型对候选打 0~10 分
成本对比(面试可讲):
| 方案 | 每次额外调用 | 适用 |
|---|---|---|
| 启发式 | 0 | 默认,性价比最高 |
| bge-reranker(本地) | 0(GPU/CPU 推理) | 生产推荐 |
| LLM 打分 | top-20 = 20 次 | 效果最好但太贵 |
七、⑥ 低分兜底:抑制幻觉的关键
python
if docs[0].vector_score < threshold and docs[0].keyword_score <= 0:
return [] # 明确告诉 Agent:知识库没覆盖
这是整个 RAG 里我最想强调的一点。
检索不到时如果硬塞一段不相关的上下文,模型会"看着它编"------ 这是企业场景幻觉的最大来源 。返回空 → Agent 明确回复"知识库未覆盖,建议人工确认", 用户体验反而更好,因为可信。
八、⑦⑧ 引用溯源:企业用户只信可溯源答案
拼上下文时给每个 chunk 编号:
csharp
[1] 来源:缺陷分级与处理规范 · 缺陷等级定义
P0 | 核心功能不可用、资损... | 15 分钟 | 4 小时
Prompt 里强制要求"句末标注 [编号]",前端把编号渲染成可展开的原文卡片。
为什么值得做 :在企业里,一个答案能不能被采用, 往往不取决于它多聪明,而取决于用户能不能验证它。 这一点比提高 2 个百分点的准确率更能决定项目能否上线。
九、向量库:三种实现同一个接口
python
class BaseVectorStore:
def add(self, chunks, vectors): ...
def search(self, vector, top_k) -> List[Tuple[Chunk, float]]: ...
| 实现 | 适用 | 说明 |
|---|---|---|
InMemoryVectorStore |
Demo | numpy 余弦,可落盘 JSON + npy |
PgVectorStore |
生产推荐 | 和业务库同实例,事务一致,HNSW 索引 |
ChromaVectorStore |
轻量独立部署 |
pgvector 建表:
sql
CREATE TABLE eap_chunks (
id TEXT PRIMARY KEY, doc_id TEXT, text TEXT,
metadata JSONB, embedding vector(1024)
);
CREATE INDEX ON eap_chunks USING hnsw (embedding vector_cosine_ops);
选 pgvector 的理由:不用再维护一套中间件,运维成本低; 数据量到千万级再考虑 Milvus。
十、效果演示
ini
GET /api/knowledge/search?query=P0 缺陷多久修复
[1] 缺陷分级与处理规范 · 缺陷等级定义 score=0.42 vector=0.31 bm25=8.7
[2] 缺陷分级与处理规范 · 处理流程 score=0.35 vector=0.35 bm25=2.1
[3] 告警推送与值班SOP · Agent自动推送约束 score=0.21 vector=0.28 bm25=0.0
前端"知识库/RAG"面板能直接看到三个分数,调参时非常直观。