RAG 知识检索:向量召回、Parent 去重与 HNSW
RAG 检索的目标是:根据用户问题快速找到相关知识,并返回足够完整的上下文。
Query Embedding、向量距离、HNSW、候选扩展、Parent 去重和检索结果。
1. 检索流程
Question
↓
Query Embedding
↓
HNSW Search Child
↓
Child Candidates
↓
Candidate Oversampling
↓
Parent Dedup
↓
Child Evidence + Parent Context
↓
Top K Results / LLM
一句话概括:
Child 负责找得准,Parent 负责给出完整上下文。
2. 为什么搜索 Child、返回 Parent
如果直接使用大块文本生成向量,一个向量容易混入多个主题,查询时不够精准;如果只返回小块文本,上下文又可能不足。
Parent–Child 将两种职责拆开:
Parent A
├─ Child A1
├─ Child A2
└─ Child A3
Child.text:生成向量并参与检索。Child.parent_text:命中后返回的完整上下文。matchedText:解释为什么命中。text:提供给用户或 LLM 阅读。
3. Query Embedding
用户问题需要使用与入库相同的模型和维度生成查询向量:
Question
→ Embedding Model
→ Query Vector
如果入库向量和查询向量使用不同模型或维度,它们不在同一个向量空间,无法正确比较。
模型调用属于可能计费的外部请求。日志应记录模型、Token、输入数量、耗时和供应商 Request ID,但不记录完整问题或 API Key。
4. Cosine Distance 与 Similarity
pgvector 使用 <=> 计算 Cosine Distance:
距离越小 → 越相似
为了更符合阅读习惯,可以转换为 Similarity:
similarity = 1 - cosine_distance
相似度越大 → 排名越靠前
Similarity 是向量检索排序分数,不是答案正确率,也不能单独证明最终回答正确。
5. 完整 pgvector 查询
创建 HNSW 索引:
CREATE INDEX IF NOT EXISTS idx_knowledge_chunks_embedding_hnsw
ON knowledge_chunks
USING hnsw (embedding vector_cosine_ops);
查询已发布内容的 Child 候选:
SELECT
kc.id,
kc.content_id,
c.title,
c.slug,
kc.chunk_index,
kc.parent_uid,
kc.section_path,
kc.text AS matched_text,
kc.parent_text,
kc.embedding <=> CAST(:query_embedding AS vector) AS cosine_distance,
1 - (kc.embedding <=> CAST(:query_embedding AS vector)) AS similarity
FROM knowledge_chunks AS kc
JOIN contents AS c ON c.id = kc.content_id
WHERE c.status = 'PUBLISHED'
ORDER BY kc.embedding <=> CAST(:query_embedding AS vector)
LIMIT :candidate_limit;
参数含义:
:query_embedding = 用户问题生成的查询向量
:candidate_limit = Child 候选数量
数据库负责召回 Child 候选;应用层再按 Parent 去重,并截取最终 Top K。
6. parent_uid 去重
同一个 Parent 下可能有多个相关 Child:
A1、A2、A3 → Parent A
B1 → Parent B
向量检索可能同时命中 A1、A2、A3。如果直接返回,会出现多条高度重复的结果。
应用层使用:
parent_key = (content_id, parent_uid)
作为去重键:
命中 Child A1 → 返回 Parent A
命中 Child A2 → Parent A 已出现,跳过
命中 Child A3 → Parent A 已出现,跳过
命中 Child B1 → 返回 Parent B
parent_uid 在检索中的作用是 Parent 分组,不负责向量复用。
7. Candidate Oversampling
最终需要 5 个 Parent 时,不能只查询 5 个 Child。因为这些 Child 可能大部分属于同一个 Parent,去重后结果数量不足。
当前策略:
先查询 top_k × 10 个 Child
→ 最多查询 100 个候选
→ 按 Parent 去重
→ 返回 Top K Parent
×10 是经验参数,不是固定最佳值。候选越多,召回更充分,但查询和应用层处理成本也越高。
应根据真实查询集评估:
- 去重后结果是否经常不足。
- 相关 Parent 是否进入候选集。
- 查询延迟是否可以接受。
8. section_path 与检索结果
section_path 是 Markdown 标题形成的章节面包屑:
部署 / 阿里云 / ECS
它用于展示命中知识来自哪个章节,不参与:
- Query Embedding。
- 向量相似度计算。
- HNSW 搜索。
- Parent 去重。
典型结果:
sectionPath → 来源章节
matchedText → 真正命中的 Child
text → Parent 完整上下文
score → 向量相似度
9. HNSW 为什么更快
没有 ANN 索引时,数据库可能将 Query Vector 与大量向量逐个计算距离,再排序取 Top K。
HNSW 属于 ANN(Approximate Nearest Neighbor,近似最近邻搜索),它把向量组织为多层图:
高层 → 快速定位大致区域
↓
低层 → 在局部继续搜索
特点:
- 查询速度快。
- 召回率通常较高。
- 不是 100% 精确搜索。
- 需要额外内存。
- 插入和更新向量时需要维护索引。
HNSW 不改变“向量距离决定排序”的逻辑,只是减少需要真正比较的向量数量。
10. 检查是否使用索引
创建 HNSW 不代表 PostgreSQL 一定会选择它。可以检查执行计划:
EXPLAIN (ANALYZE, BUFFERS)
SELECT
kc.id,
kc.content_id,
c.title,
c.slug,
kc.chunk_index,
kc.parent_uid,
kc.section_path,
kc.text AS matched_text,
kc.parent_text,
kc.embedding <=> CAST(:query_embedding AS vector) AS cosine_distance,
1 - (kc.embedding <=> CAST(:query_embedding AS vector)) AS similarity
FROM knowledge_chunks AS kc
JOIN contents AS c ON c.id = kc.content_id
WHERE c.status = 'PUBLISHED'
ORDER BY kc.embedding <=> CAST(:query_embedding AS vector)
LIMIT :candidate_limit;
重点观察:
Index Scan → 使用了索引
Seq Scan → 执行了顺序扫描
小数据量时 PostgreSQL 认为顺序扫描更便宜,出现 Seq Scan 不一定是问题。不要为了强制显示索引而盲目调整数据库参数。
11. 检索质量如何评估
不要只看单次查询的相似度,需要准备一组真实问题作为评估集:
| 观察项 | 问题 |
|---|---|
| Recall | 正确 Parent 是否进入候选集? |
| Ranking | 正确结果是否排在前面? |
| Context | 返回的 Parent 是否足够回答问题? |
| Duplication | 去重后是否仍有重复内容? |
| Latency | 查询耗时是否可以接受? |
先通过真实问题确认瓶颈,再决定是否增加关键词检索、Hybrid Search 或 Rerank。
12. 后续优化方向
按实际需求逐步增加:
- 调整
top_k和 Oversampling 倍数。 - 使用真实问题建立召回评估集。
- 对专业术语、错误码增加全文关键词检索。
- 将向量召回和关键词召回合并为 Hybrid Search。
- 候选质量不足时增加 Rerank。
- 数据量增大后调优 HNSW 参数。
中小规模知识库先使用“向量召回 + Parent 去重”通常已经足够,不需要一开始就堆叠全部组件。
总结
Query Embedding → 把问题转换成向量
HNSW → 快速召回相关 Child
Oversampling → 为 Parent 去重准备足够候选
parent_uid → 合并同一个 Parent 的多个 Child
Parent Context → 提供完整回答上下文
检索优化的核心不是让 SQL 看起来更复杂,而是确保正确知识能进入候选集、排在前面,并以足够完整且不重复的上下文返回。