拆开 AI 记忆引擎的 PostgreSQL 老底:Hindsight 如何用 SQL 模板替代 Text2SQL,472ms 完成语义召回

预计阅读时间:14 分钟

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-EncoderStage 1):query  document 各自独立编码  余弦
Cross-EncoderStage 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 只在两个地方出现:

  1. 写入路径:提取事实、翻译 fact、consolidation 去重合并
  2. 读路径末端:cross-encoder 做最后的语义校验打分

所有检索 SQL 都是编译时写死的模板,运行时只改变量参数。这是传统信息检索(IR)引擎的架构,只不过用现代向量+PG 扩展替代了 Elasticsearch。


七、给你的启示

如果你在构建自己的 AI 记忆系统,Hindsight 的架构至少有三点可以直接复用:

  1. PostgreSQL 一站到底:pgvector 的 HNSW + pg_trgm + tsvector 三件套已经足够支撑混合检索。不需要 Elasticsearch、不需要 Pinecone、不需要 Milvus。
  2. 不要用 LLM 生成 SQL:确定性、低延迟、零幻觉。一个 SQL 模板 + 参数注入 = text2sql 的最优解。
  3. 三型 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
暂无评论,来发表第一条评论吧

发表评论

登录 后发表评论

发现更多