TL;DR:Hindsight 0.8.0 的「text2sql」根本不是让 LLM 写 SQL。你输入的自然语言查询,经过 bge-m3 变成 1024 维向量,注入到硬编码的 SQL 模板参数里,然后 PostgreSQL 的 HNSW 索引 + tsvector 全文检索 + 实体图谱自连接三管齐下,在 472ms 内完成从 600+ 条候选中精排出 Top 20-50 条结果。本文从源码级拆解完整链路:9 张 PG 表、18 个索引、4 通道并行检索、RRF 融合、Cross-Encoder 重排序——每一步都附真实 SQL 和执行数据。
一、先看全局:两条管道,九张表
Hindsight 的数据流分两条管道:
┌── WRITE PATH(Retain)──────────────────────────────┐
│ API POST → Document → Chunk → LLM Extract → Embed │
│ ↓ │
│ Entity Resolution │
│ ↓ ↓ │
│ memory_units ← memory_links │
│ ↓ │
│ Consolidation(异步 LLM 去重) │
└─────────────────────────────────────────────────────┘
┌── READ PATH(Recall)───────────────────────────────┐
│ Query → Embedding → 4-way Parallel → RRF → Rerank │
│ 84ms 352ms 2ms 3ms │
│ → Token Filter │
│ 4ms │
└─────────────────────────────────────────────────────┘
PostgreSQL 里躺着 9 张核心表,每一张都有明确的分工:
| 表 | 用途 | 数据量(hermes bank) |
|---|---|---|
documents |
原始文本 + 元数据 | ~42 |
chunks |
切割后的文本片段 | ~161 |
memory_units |
提取的 fact + embedding + tsvector | ~585 |
entities |
命名实体(CSDN/Hindsight/用户…) | ~200+ |
unit_entities |
fact ↔ entity 多对多关联 | ~1000+ |
entity_cooccurrences |
实体共现计数(图权重) | ~2000+ |
memory_links |
图谱边:temporal / semantic / entity / causal | ~4000+ |
observation_history |
Consolidation 变更记录 | ~若干 |
banks |
Bank 配置(entity_labels / retain_mission) | 1 |
FK 链:
documents ──1:N──→ chunks ──1:N──→ memory_units
│ │ │
unit_entities ◄────┘ │ └── memory_links
│ │
entities ◄────────────┘
二、写路径:一条文本如何变成可检索的 fact
2.1 分块存储(Chunk)
当你调用 hindsight_retain("Hindsight 召回架构分析…"),第一步是按句/段切割文本:
# chunk_storage.py
chunk_id = f"{bank_id}_{document_id}_{chunk_index}"
# 例如: "hermes_7bdc5e7f-bdfc-4493-be75-1931257af76a_6"
每条 chunk 独立存储,带 SHA256 content_hash 用于增量更新。CASCADE DELETE 保证删 document 时 chunk 和 memory_units 子行全部自动清除。
2.2 LLM 提取事实(Fact Extraction)
这是 Hindsight 唯一依赖 LLM 的步骤——用 DeepSeek V4 Flash 读 chunk 文本,提取结构化 fact:
输入:chunk_text
输出:Fact {
fact: "Hindsight v0.8.0 recall 延迟从 0.5s 降到 201ms,快 3 倍"
fact_type: "experience" # 或 "world"(英文翻译版)
entities: ["Hindsight", "v0.8.0", "recall"]
occurred_start: 2026-06-09
}
三种 fact_type 的分工:
| fact_type | 含义 | 生产方 |
|---|---|---|
experience |
中文原始事实 | LLM 直接提取 |
world |
英文翻译版 | LLM 自动翻译(实现双语召回) |
observation |
去重精炼知识 | Consolidation 阶段 LLM 合并 |
2.3 实体解析(Entity Resolution)
提取到的实体(如"Hindsight"、"CSDN")与 entities 表做模糊匹配:
-- 用 pg_trgm 扩展的 trigram GIN 索引做模糊查找
SELECT * FROM entities
WHERE bank_id = 'hermes'
AND lower(canonical_name) % 'hindsight'; -- trigram 相似度
命中 → 复用已有 entity,mention_count++;未命中 → 创建新 entity。然后写入 unit_entities 多对多关联表。
2.4 Embedding 生成
关键设计:embedding 输入的不是 raw fact_text,而是增强后的文本:
# embedding_processing.py
augmented_text = f"{fact.fact_text} (happened in {readable_date}) [{entities_str}]"
# 实际效果:
# "Hindsight v0.8.0 recall 延迟降低到 201ms (happened in June 2026) [Hindsight, recall, v0.8.0]"
embedding = bge_m3.encode(augmented_text) # → 1024-dim float32 vector
为什么增强? 用户查"June 的 recall 性能",如果没有日期增强,原始 fact 中只有时间戳没有"June"这个单词,语义匹配就会漏掉。
2.5 写入 memory_units
最终一行 fact 的核心字段:
INSERT INTO memory_units (
id, bank_id, document_id, text,
embedding, -- 1024-dim pgvector
search_vector, -- tsvector(全文搜索)
text_signals, -- "Hindsight PATCH bank config June 12 2026"(关键词补充)
fact_type, -- 'experience'|'world'|'observation'
tags, -- '{topic:infra, stage:decision, session:xxx}'
source_memory_ids, -- observation 的原始 fact ID 追溯
chunk_id,
occurred_start, occurred_end, mentioned_at
) VALUES (...)
2.6 异步 Consolidation
后台定时任务扫描 consolidated_at IS NULL 的 experience/world fact,调用 LLM 按 topic 分组 → 去重 → 合并 → 生成 observation。observation 的 source_memory_ids 指向原始 fact UUID,原始 fact 的 consolidated_at 标记为已处理。
三、存储层:18 个索引如何支撑混合检索
memory_units 表上有 关键索引,每个对应一种检索通道:
-- 1. 语义通道:HNSW 近似近邻
CREATE INDEX idx_memory_units_embedding_hnsw
ON memory_units USING hnsw (embedding vector_cosine_ops);
-- 2. 按 fact_type 的部分 HNSW(避免全表扫描)
CREATE INDEX idx_mu_emb_obsv ON memory_units
USING hnsw (embedding vector_cosine_ops)
WHERE fact_type = 'observation' AND bank_id = 'hermes';
CREATE INDEX idx_mu_emb_worl ON memory_units
USING hnsw (embedding vector_cosine_ops)
WHERE fact_type = 'world' AND bank_id = 'hermes';
CREATE INDEX idx_mu_emb_expr ON memory_units
USING hnsw (embedding vector_cosine_ops)
WHERE fact_type = 'experience' AND bank_id = 'hermes';
-- 3. 关键词通道:GIN 全文搜索
CREATE INDEX idx_memory_units_text_search
ON memory_units USING gin (search_vector);
-- 4. 标签过滤:GIN 数组索引
CREATE INDEX idx_memory_units_tags
ON memory_units USING gin (tags);
-- 5. 实体图谱:trigram 模糊匹配
CREATE INDEX entities_canonical_name_lower_trgm_idx
ON entities USING gin (lower(canonical_name) gin_trgm_ops);
-- 6. 图谱遍历:双向索引
CREATE INDEX idx_memory_links_from_type_weight
ON memory_links (from_unit_id, link_type, weight DESC);
CREATE INDEX idx_memory_links_to_type_weight
ON memory_links (to_unit_id, link_type, weight DESC);
为什么用部分 HNSW 索引? 源码直接解释了原因——如果你用一个全量 HNSW 索引 + ROW_NUMBER() OVER (PARTITION BY fact_type) 做 per-type LIMIT,PostgreSQL 的查询规划器会走全表扫描。拆成三个部分索引,每个 ORDER BY … LIMIT 直接走自己的 HNSW,零全表扫描。
四、读路径:472ms 端到端的完整检索管线
以查询 "Hindsight recall architecture" 为例,走一遍完整链路:
Stage 0:Query Embedding(84ms)
# query → bge-m3 → 1024-dim vector
query_emb = bge_m3.encode(["Hindsight recall architecture"])[0]
# → [-0.041, -0.036, 0.001, -0.021, ...](1024 个 float32)
Stage 1:四通道并行检索(352ms)
这就是「text2sql」的真相——不是 LLM 生成 SQL,而是硬编码模板 + 参数注入:
通道 A:语义检索(Semantic)
-- PostgreSQLDialect.build_semantic_arm()
(SELECT id, text, context, event_date, occurred_start, occurred_end,
mentioned_at, fact_type, document_id, chunk_id, tags, metadata, proof_count,
1 - (embedding <=> $1::vector) AS similarity, -- 余弦相似度
NULL::float AS bm25_score,
'semantic' AS source
FROM memory_units
WHERE bank_id = $2
AND fact_type = 'observation'
AND embedding IS NOT NULL
AND (1 - (embedding <=> $1::vector)) >= 0.3 -- 最低相似度阈值
ORDER BY embedding <=> $1::vector -- HNSW 索引加速
LIMIT 500) -- 5x over-fetch
通道 B:关键词检索(BM25)
-- PostgreSQLDialect.build_bm25_arm()
(SELECT id, text, context, ...,
NULL::float AS similarity,
ts_rank_cd(search_vector, to_tsquery('english', $4)) AS bm25_score,
'bm25' AS source
FROM memory_units
WHERE bank_id = $2
AND fact_type = 'observation'
AND search_vector @@ to_tsquery('english', $4) -- @@ 是 tsvector 匹配运算符
ORDER BY ts_rank_cd(search_vector, to_tsquery('english', $4)) DESC
LIMIT $3)
通道 C:实体图谱(Graph)
通过 unit_entities + memory_links 自连接,从语义种子出发扩展:
-- LinkExpansionRetriever:三条 SQL 分别走 entity/semantic/causal 边
-- Entity 扩展:通过共享实体找到候选
SELECT DISTINCT mu.*, COUNT(DISTINCT ue.entity_id) AS graph_score
FROM unit_entities ue
JOIN unit_entities seed_ue ON ue.entity_id = seed_ue.entity_id
JOIN memory_units mu ON ue.unit_id = mu.id
WHERE seed_ue.unit_id = ANY($1::uuid[]) -- 语义种子
GROUP BY mu.id
ORDER BY graph_score DESC;
通道 D:时间约束(Temporal)
用 dateparser 解析 query 中的时间表达式("last week" → datetime range),然后过滤 occurred_start/occurred_end。
九路汇总
三个 fact_type(observation/world/experience)× 三种方法(semantic/bm25/graph)= 9 路并行:
semantic/observation: 198 hits, top cosine=0.67
bm25/observation: 0 hits (纯语义查询无关键词命中)
graph/observation: 58 hits, entity score=3
semantic/world: 131 hits, top cosine=0.62
bm25/world: 33 hits
graph/world: 16 hits
semantic/experience: 164 hits, top cosine=0.59
bm25/experience: 35 hits
graph/experience: 59 hits, entity score=3
────────────────────
合计 694 raw hits
Stage 2:RRF 融合(2ms)
Reciprocal Rank Fusion——解决三种通道分数分布不同的问题:
semantic 得分:0.0 ~ 1.0(余弦相似度)
bm25 得分: 0.0 ~ 0.5(ts_rank_cd)
graph 得分: 1 ~ 3(整数,实体重叠深度)
直接加权求和会扭曲权重。RRF 把所有分数转为排名再融合:
RRF_score(d) = Σ ( 1 / (60 + rank_i(d)) ) # 对每个通道 i
例如一条 fact 在 semantic 排第 12、bm25 排第 18、graph 排第 70:
RRF_score = 1/(60+12) + 1/(60+18) + 1/(60+70) = 0.0139 + 0.0128 + 0.0077 = 0.0344
694 raw → 去重(按 node_id)→ 508 unique candidates。
Stage 3:Cross-Encoder 精排(3ms)
这是整个管线的灵魂步骤。区别在于:
Bi-Encoder(Stage 1):query 和 document 各自独立编码 → 余弦
Cross-Encoder(Stage 3):[CLS] query [SEP] document [SEP] → 联合打分
↑ 注意力跨 query 和 document 交互
模型:ms-marco-MiniLM-L-6-v2(80MB,纯 CPU 推理,3ms 处理 300 条候选)
综合评分公式:
recency_boost = 1 + 0.2 × (recency - 0.5) # 新鲜度:±10%
temporal_boost = 1 + 0.2 × (temporal - 0.5) # 时间约束:±10%
proof_count_boost = 1 + 0.1 × (proof_norm - 0.5) # 证据强度:±5%
combined_score = CE_normalized × recency_boost × temporal_boost × proof_count_boost
实测一条 fact 的打分过程:
{
'cross_encoder_score_normalized': 0.997, # 联合语义得分,主导权重
'recency': 0.983, # 201ms 内的事实,新鲜度加成
'temporal': 0.500, # 无时间约束,中性
'rrf_score': 0.033, # RRF 得分已被 CE 压制
'combined_score': 1.093 # 最终得分
}
Stage 4:Token 截断(4ms)
按 combined_score 降序 → 贪心选取 → 累计 token 数触及 max_tokens 上限(2048/4096)→ 停止。
结果:24 条选中,1974/2048 tokens 使用
五、完整耗时拆解
generate_query_embedding 84ms ██████████████
parallel_retrieval 352ms ███████████████████████████████████████████████
├─ semantic (3路) 10ms ─ 真正的 HNSW 索引扫描极快
├─ bm25 (3路) 10ms ─ GIN tsvector 索引同样毫秒级
├─ graph (3路) 23ms ─ 实体自连接
└─ temporal extraction 305ms ← 86% 耗时!dateparser 解析时间表达式
rrf_merge 2ms █
reranking (cross-encoder) 3ms █
token_filtering 4ms █
─────────────────────────────────
总计 472ms
关键发现:纯检索+精排核心路径不到 170ms。305ms 的 temporal extraction 对于无时间约束的查询是纯浪费——如果配置跳过或缓存 dateparser 结果,极致性能可以做到 <200ms。
六、核心认知:这不是 Text2SQL
如果把「text2sql」理解为「LLM 把自然语言转成 SQL」——那 Hindsight 完全不是。
实际情况是:
┌──────────────────────────────────────────────────────┐
│ 你以为的 text2sql: │
│ "查一下上周关于 Hindsight 的文章" │
│ ↓ LLM 推理 │
│ SELECT * FROM memory_units WHERE ... │
│ │
│ 实际 Hindsight 的做法: │
│ "查一下上周关于 Hindsight 的文章" │
│ ↓ dateparser(规则引擎) │
│ temporal_constraint = [2026-06-11, 2026-06-17] │
│ ↓ bge-m3(向量模型) │
│ query_emb = [0.041, -0.036, ...] (1024 floats) │
│ ↓ 注入硬编码 SQL 模板的 $1 参数 │
│ SELECT ... FROM memory_units │
│ WHERE embedding <=> $1::vector ... │
│ AND search_vector @@ to_tsquery('english', $4) │
│ AND occurred_start BETWEEN $5 AND $6 │
└──────────────────────────────────────────────────────┘
LLM 不参与 SQL 生成。 LLM 只在两个地方出现:
- 写入路径:提取事实、翻译 fact、consolidation 去重合并
- 读路径末端:cross-encoder 做最后的语义校验打分
所有检索 SQL 都是编译时写死的模板,运行时只改变量参数。这是传统信息检索(IR)引擎的架构,只不过用现代向量+PG 扩展替代了 Elasticsearch。
七、给你的启示
如果你在构建自己的 AI 记忆系统,Hindsight 的架构至少有三点可以直接复用:
- PostgreSQL 一站到底:pgvector 的 HNSW + pg_trgm + tsvector 三件套已经足够支撑混合检索。不需要 Elasticsearch、不需要 Pinecone、不需要 Milvus。
- 不要用 LLM 生成 SQL:确定性、低延迟、零幻觉。一个 SQL 模板 + 参数注入 = text2sql 的最优解。
- 三型 fact 分离存:observation(去重知识)/ world(双语拉通)/ experience(原始事实),分层索引 + 独立 HNSW 部分索引,兼顾召回率和检索性能。
数据声明:本文所有 SQL 模板、表结构、索引定义和性能数据均来自 Hindsight 0.8.0-slim 运行实例的实际
\d输出、EXPLAIN执行计划和 trace 日志。工具链:PostgreSQL 18 + pgvector 0.8.1 + bge-m3 (vLLM) + ms-marco-MiniLM-L-6-v2 (CPU)。
本文由 admin 原创,转载请注明出处。
评论
0