DaemonDaemonAI LAB
DAEMON / ARTICLE

RAG 知识检索:向量召回、Parent 去重与 HNSW

2026.09.16 · 12 MIN READ

RAG 检索的目标是:根据用户问题快速找到相关知识,并返回足够完整的上下文。

Query Embedding、向量距离、HNSW、候选扩展、Parent 去重和检索结果。

1. 检索流程

text
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 将两种职责拆开:

text
Parent A
├─ Child A1
├─ Child A2
└─ Child A3

3. Query Embedding

用户问题需要使用与入库相同的模型和维度生成查询向量:

text
Question
→ Embedding Model
→ Query Vector

如果入库向量和查询向量使用不同模型或维度,它们不在同一个向量空间,无法正确比较。

模型调用属于可能计费的外部请求。日志应记录模型、Token、输入数量、耗时和供应商 Request ID,但不记录完整问题或 API Key。

4. Cosine Distance 与 Similarity

pgvector 使用 <=> 计算 Cosine Distance:

text
距离越小 → 越相似

为了更符合阅读习惯,可以转换为 Similarity:

text
similarity = 1 - cosine_distance
text
相似度越大 → 排名越靠前

Similarity 是向量检索排序分数,不是答案正确率,也不能单独证明最终回答正确。

5. 完整 pgvector 查询

创建 HNSW 索引:

sql
CREATE INDEX IF NOT EXISTS idx_knowledge_chunks_embedding_hnsw
ON knowledge_chunks
USING hnsw (embedding vector_cosine_ops);

查询已发布内容的 Child 候选:

sql
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;

参数含义:

text
:query_embedding = 用户问题生成的查询向量
:candidate_limit = Child 候选数量

数据库负责召回 Child 候选;应用层再按 Parent 去重,并截取最终 Top K。

6. parent_uid 去重

同一个 Parent 下可能有多个相关 Child:

text
A1、A2、A3 → Parent A
B1         → Parent B

向量检索可能同时命中 A1、A2、A3。如果直接返回,会出现多条高度重复的结果。

应用层使用:

python
parent_key = (content_id, parent_uid)

作为去重键:

text
命中 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,去重后结果数量不足。

当前策略:

text
先查询 top_k × 10 个 Child
→ 最多查询 100 个候选
→ 按 Parent 去重
→ 返回 Top K Parent

×10 是经验参数,不是固定最佳值。候选越多,召回更充分,但查询和应用层处理成本也越高。

应根据真实查询集评估:

8. section_path 与检索结果

section_path 是 Markdown 标题形成的章节面包屑:

text
部署 / 阿里云 / ECS

它用于展示命中知识来自哪个章节,不参与:

典型结果:

text
sectionPath  → 来源章节
matchedText  → 真正命中的 Child
text         → Parent 完整上下文
score        → 向量相似度

9. HNSW 为什么更快

没有 ANN 索引时,数据库可能将 Query Vector 与大量向量逐个计算距离,再排序取 Top K。

HNSW 属于 ANN(Approximate Nearest Neighbor,近似最近邻搜索),它把向量组织为多层图:

text
高层 → 快速定位大致区域
  ↓
低层 → 在局部继续搜索

特点:

HNSW 不改变“向量距离决定排序”的逻辑,只是减少需要真正比较的向量数量。

10. 检查是否使用索引

创建 HNSW 不代表 PostgreSQL 一定会选择它。可以检查执行计划:

sql
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;

重点观察:

text
Index Scan → 使用了索引
Seq Scan   → 执行了顺序扫描

小数据量时 PostgreSQL 认为顺序扫描更便宜,出现 Seq Scan 不一定是问题。不要为了强制显示索引而盲目调整数据库参数。

11. 检索质量如何评估

不要只看单次查询的相似度,需要准备一组真实问题作为评估集:

观察项问题
Recall正确 Parent 是否进入候选集?
Ranking正确结果是否排在前面?
Context返回的 Parent 是否足够回答问题?
Duplication去重后是否仍有重复内容?
Latency查询耗时是否可以接受?

先通过真实问题确认瓶颈,再决定是否增加关键词检索、Hybrid Search 或 Rerank。

12. 后续优化方向

按实际需求逐步增加:

  1. 调整 top_k 和 Oversampling 倍数。
  2. 使用真实问题建立召回评估集。
  3. 对专业术语、错误码增加全文关键词检索。
  4. 将向量召回和关键词召回合并为 Hybrid Search。
  5. 候选质量不足时增加 Rerank。
  6. 数据量增大后调优 HNSW 参数。

中小规模知识库先使用“向量召回 + Parent 去重”通常已经足够,不需要一开始就堆叠全部组件。

总结

text
Query Embedding → 把问题转换成向量
HNSW → 快速召回相关 Child
Oversampling → 为 Parent 去重准备足够候选
parent_uid → 合并同一个 Parent 的多个 Child
Parent Context → 提供完整回答上下文

检索优化的核心不是让 SQL 看起来更复杂,而是确保正确知识能进入候选集、排在前面,并以足够完整且不重复的上下文返回。