TL;DR :上一篇文章拆解了 Hindsight 0.8.0 如何用 SQL 模板替代 Text2SQL(9 张表、18 个索引、472ms 端到端召回)。这篇继续深挖两件被忽略的关键设计:① 检索 SQL 不是手写死字符串,而是一层方言抽象------同一个模板可以无缝切换 tsvector / VectorChord / pgroonga / pg_search 四种全文后端;② 时间约束解析走「规则引擎优先 + 小模型兜底」的双轨设计,避免了 305ms 的 dateparser 冷启动浪费。这两处改造让真实环境的端到端召回从 472ms 降到 150-270ms。本文所有代码来自 Hindsight 0.8.0-slim 运行实例的容器内源码,性能数据为真实 API 实测。
一、回顾:上一篇的结论还成立吗
上一篇《拆开 AI 记忆引擎的 PostgreSQL 老底》的核心结论是:Hindsight 的「text2sql」不是让 LLM 写 SQL,而是硬编码模板 + 参数注入。当时拆出的读路径是:
scss
Query → Embedding(84ms) → 4-way Parallel(352ms) → RRF(2ms) → Rerank(3ms) → Token Filter(4ms)
其中 352ms 的并行检索里,temporal extraction 占 305ms(86%)------查询根本没有时间约束,dateparser 却白白解析了 305ms。
本篇回答两个问题:
- 那 305ms 现在去哪了?------真实环境实测已经降到 150-270ms 端到端
- SQL 模板到底是「写死的字符串」还是「有设计的分层」?------答案是后者
二、方言抽象层:一个模板,四种全文后端
2.1 不是「写死的 SQL」,是「SQL 方言接口」
Hindsight 的检索 SQL 来自 engine/sql/base.py 的 SQLDialect 抽象基类。先看它的方法面:
python
# engine/sql/base.py(节选,实际 200+ 行接口)
class SQLDialect(ABC):
@abstractmethod
def param(self, n: int) -> str: ... # 参数占位符:PG 是 $1,Oracle 是 :1
@abstractmethod
def vector_distance(self, col, param) -> str: ... # 向量距离:PG 是 <=>,Oracle 是 VECTOR_DISTANCE
@abstractmethod
def text_search_score(self, col, query_param, *, index_name=None) -> str: ...
@abstractmethod
def upsert(self, table, columns, conflict_columns, update_columns) -> str: ...
@abstractmethod
def bulk_unnest(self, param_types) -> str: ... # 批量 unnest 展开
@abstractmethod
def advisory_lock(self, id_param) -> str: ... # pg_try_advisory_lock 咨询锁
设计意图 :Hindsight 官方同时支持 PostgreSQL 和 Oracle 两种后端(engine/sql/postgresql.py 与 engine/sql/oracle.py),所有业务代码只依赖这个接口,不直接拼接 SQL。PostgreSQLDialect 的差异点集中在:
| 能力 | PostgreSQL 实现 | Oracle 实现 |
|---|---|---|
| 参数绑定 | $1 |
:1 |
| 向量距离 | embedding <=> $1::vector |
VECTOR_DISTANCE(embedding, :1) |
| 全文检索 | tsvector / VectorChord | Oracle Text |
| 批量插入 | unnest($1::uuid[], $2::text[]) |
循环 INSERT |
| 咨询锁 | pg_try_advisory_lock($1) |
DBMS_LOCK |
这就是「模板化」和「写死 SQL」的本质区别:模板是参数化的,参数里有「方言」这一维。换数据库后端时业务代码零改动。
2.2 语义检索臂:一个方法生成完整子查询
上一篇展示了 build_semantic_arm() 生成的 SQL 长什么样,这里看它的方法签名------模板的入参就是它灵活性的来源:
python
# engine/sql/postgresql.py
def build_semantic_arm(
self, *,
table: str, # 表名
cols: str, # 要 SELECT 的列
fact_type: str, # observation / world / experience
embedding_param: str, # $1 查询向量
bank_id_param: str, # $2 bank 过滤
fetch_limit: int, # 拉取上限(HNSW 近似,实际 5x over-fetch)
min_similarity: float, # 最低相似度阈值 0.3
tags_clause: str = "", # 标签过滤(可选)
groups_clause: str = "", # 标签组过滤(可选)
extra_where: str = "", # 扩展条件(时间过滤等)
) -> str:
return (
f"(SELECT {cols},"
f" 1 - (embedding <=> {embedding_param}::vector) AS similarity,"
f" NULL::float AS bm25_score,"
f" 'semantic' AS source"
f" FROM {table}"
f" WHERE bank_id = {bank_id_param}"
f" AND fact_type = '{fact_type}'"
f" AND embedding IS NOT NULL"
f" AND (1 - (embedding <=> {embedding_param}::vector)) >= {min_similarity}"
f" {tags_clause}{groups_clause}{extra_where}"
f" ORDER BY embedding <=> {embedding_param}::vector"
f" LIMIT {fetch_limit})"
)
注意 fact_type 是内联字面量而不是参数------源码注释写得很清楚:「fact_type 来自受控内部枚举,永不来自用户输入,内联是安全的」。这是模板化与「LLM 生成 SQL」的另一层差异:模板作者显式控制哪些进参数、哪些内联,SQL 注入面为零。
2.3 BM25 臂:四种全文后端一 switch 切换
build_bm25_arm() 是方言层最精彩的部分------它按配置的 text_search_extension 走四个分支:
| 后端 | 扩展 | 打分表达式 | 使用场景 |
|---|---|---|---|
| native | 内置 tsvector | ts_rank_cd(search_vector, to_tsquery('english', $4)) |
默认,零依赖 |
| vchord | VectorChord | -(search_vector <&> to_bm25query('idx...', tokenize($4, 'llmlingua2'))) |
高精度 BM25 |
| pgroonga | PGroonga | pgroonga_score(tableoid, ctid) + &@~ 操作符 |
中文分词友好 |
| pg_search | ParadeDB | paradedb.score(id) + @@@ 操作符 |
大规模 BM25 |
python
# engine/sql/postgresql.py(BM25 分支节选)
if text_search_extension == "vchord":
# <&> 返回 NEGATIVE BM25 分数(越小越相关),取负转正
bm25_score_expr = f"-(search_vector <&> to_bm25query('idx_memory_units_text_search', tokenize({text_param}, 'llmlingua2')))"
bm25_order_by = f"{bm25_score_expr} DESC"
# VectorChord 对每个文档都打分,不像 tsvector 有布尔 @@ 门槛,
# 必须用 score > min 过滤,否则 LIMIT 会用零分不相关行填满
bm25_where_filter = f"AND {bm25_score_expr} > {bm25_min_score:g}"
elif text_search_extension == "pgroonga":
# &@~ 接受 pgroonga 查询语法;转义文本避免记忆内容里的 ">" "(" 被解析成查询
bm25_score_expr = "pgroonga_score(tableoid, ctid)"
bm25_where_filter = (
f"AND (COALESCE(text,'') || ' ' || COALESCE(context,'') || ' ' || COALESCE(text_signals,'')) "
f"&@~ pgroonga_query_escape({text_param})"
)
elif text_search_extension == "pg_search":
# ParadeDB:@@@ 需要字段限定查询,把 query 扇出到所有索引字段
bm25_where_filter = (
f"AND id @@@ paradedb.boolean(should => ARRAY["
f"paradedb.match('text', {text_param}), "
f"paradedb.match('context', {text_param}), "
f"paradedb.match('text_signals', {text_param})])"
)
else: # native tsvector
bm25_where_filter = f"AND search_vector @@ to_tsquery('{bm25_language}', {text_param})"
这段代码还藏着三个真实的工程决策:
- VectorChord 没有布尔门槛 :
@@匹配是 tsvector 的布尔语义,VectorChord 却对所有行打分------所以必须显式> min_score过滤,否则 LIMIT 填满不相关行。这是只用过 tsvector 的人想不到的坑。 - pgroonga 必须转义 :用户记忆文本里可能含
>、(等查询语法字符,不转义会被解析成 malformed query。 - 方言切换不需要改上层 :
retrieval.py只调dialect.build_bm25_arm(text_search_extension=config.text_search_extension),后端由配置决定。
2.4 为什么 semantic + BM25 合并成一条 UNION ALL
上篇拆的是「9 路并行」(3 fact_type × 3 方法)。本篇的源码已经演进为语义 + BM25 合并成一条 UNION ALL 查询 ,图检索单独并行。retrieval.py 的注释解释了原因:
Uses UNION ALL of per-fact_type subqueries so that each arm has its own ORDER BY ... LIMIT, enabling the partial HNSW indexes per fact_type instead of forcing a full sequential scan (which the previous window-function approach caused by using PARTITION BY inside ROW_NUMBER()).
这是一次真实的反模式修复 :早期实现用 ROW_NUMBER() OVER (PARTITION BY fact_type) 窗口函数在单次扫描里按类型分组取 Top N------PostgreSQL 规划器碰到窗口函数就放弃 HNSW 索引,走全表扫描。改成「每个 fact_type 一个 UNION ALL 子查询,各自 ORDER BY ... LIMIT」后,每个子查询都能命中自己的部分 HNSW 索引。
同时语义臂还做了 5x over-fetch (LIMIT 500 而不是 LIMIT 100)+ Python 侧裁剪------因为 HNSW 是近似索引,ef_search=200 的召回率有损,多拉 5 倍再精确裁剪能补回损失。这两处细节解释了为什么上篇看到的「semantic 10ms 就返回」:不是数据少,是部分索引 + UNION ALL 的功劳。
三、那 305ms 去哪了:查询分析器的双轨设计
3.1 上一篇文章埋的雷
上一篇明确写了:「305ms 的 temporal extraction 对于无时间约束的查询是纯浪费」。当时我给的优化建议是「配置跳过或缓存 dateparser 结果」。Hindsight 实际选择的方案更聪明------它没有简单跳过,而是把查询分析器拆成两条轨道:
scss
QueryAnalyzer (抽象基类)
├── DateparserQueryAnalyzer → 规则优先,dateparser 兜底(200+ 语言)
└── TransformerQueryAnalyzer → 规则优先,T5 小模型兜底(80M 参数)
两个实现共享同一个接口 analyze(query, reference_date) -> QueryAnalysis,但策略不同。
3.2 DateparserQueryAnalyzer:把冷启动成本挪到加载期
上篇的 305ms 来自 dateparser.search_dates() 首次调用时的惰性初始化 (正则表、时区数据)。Hindsight 的解法在 load():
python
# engine/query_analyzer.py
class DateparserQueryAnalyzer(QueryAnalyzer):
def load(self) -> None:
"""Triggers the real initialization cost (regex tables, timezone data) at
load time so the first actual recall doesn't pay the cold-start penalty."""
if self._search_dates is None:
from dateparser.search import search_dates
self._search_dates = search_dates
# Warm up: fire a dummy call to trigger lazy-loaded internal tables.
self._search_dates("today")
def analyze(self, query, reference_date=None) -> QueryAnalysis:
...
# Check for period expressions first (these need special handling)
period_result = self._extract_period(query_lower, reference_date)
if period_result is not None:
return QueryAnalysis(temporal_constraint=period_result)
# Lazy load dateparser (only imports on first call, then cached)
self.load()
...
关键有两处:
load()预热 :在引擎初始化阶段就调一次search_dates("today"),把正则表、时区数据的初始化成本从「第一次 recall」挪到「服务启动时」。用户无感知,因为启动阶段本来就有模型加载时间。_extract_period()规则优先 :yesterday / today / last week / last month / last year / June 2024等高频时间表达,用正则直接算日期范围,根本不进 dateparser。这些规则覆盖了日常查询的 90%+,且纯 CPU 微秒级。
_extract_period() 的正则还支持多语言(英西意法德):
python
# Last week patterns
if re.search(
r"\b(last\s+week|la\s+semana\s+pasada|la\s+settimana\s+scorsa|la\s+semaine\s+derni[eè]re|letzte\s+woche)\b",
query, re.IGNORECASE):
start = reference_date - timedelta(days=reference_date.weekday() + 7)
return constraint(start, start + timedelta(days=6))
3.3 防御性容错:dateparser 崩了不拖垮检索
源码里还有一处容易被忽略但值得学习的容错:
python
# Wrap dateparser in a defensive try/except. dateparser has been
# observed to crash with internal errors (e.g., IndexError from
# locale.translate_search) on certain query inputs. A parser bug
# should not bring down the whole search/consolidation pipeline.
try:
results = self._search_dates(query, settings=settings)
except Exception as e:
logger.warning("dateparser raised %s on query (treating as no temporal constraint): %s", type(e).__name__, e)
return QueryAnalysis(temporal_constraint=None)
注释里明说了 dateparser 会崩 (IndexError from locale.translate_search)。任何解析器 bug 都被降级为「无时间约束」,而不是让整个召回管线 500。这个模式对所有外部依赖都适用:解析失败 ≠ 系统失败,降级重试才是生产级设计。
还有一层假阳性过滤:
python
# Filter out false positives (common words parsed as dates)
false_positives = {"do", "may", "march", "will", "can", "sat", "sun", "mon", "tue", "wed", "thu", "fri"}
valid_results = [(text, date) for text, date in results if text.lower() not in false_positives or len(text) > 3]
may(五月/可能)、march(三月/行进)、sat/sun/mon(星期几缩写)会被误解析成日期,这里直接过滤。
3.4 TransformerQueryAnalyzer:规则优先 + T5 兜底
当查询包含复杂时间表达(如「从感恩节到圣诞节之间」)时,规则引擎失效。Hindsight 提供了第二个实现:TransformerQueryAnalyzer,用 google/flan-t5-small(80M 参数,约 300MB)做生成式解析。
它的设计也遵循「规则优先」:_extract_with_rules() 覆盖 yesterday / last week / last month / last year / last weekend / last <weekday> / June 2024 等常规模式,只有规则没命中才加载模型:
python
# 90%+ 情况规则搞定,模型只在冷门模式才加载
result = self._extract_with_rules(query, reference_date)
if result is not None:
return QueryAnalysis(temporal_constraint=result)
# Fall back to T5 model for unusual patterns
self._load_model()
T5 的 prompt 也很有设计感------few-shot 示例帮助小模型对齐输出格式:
python
prompt = f"""Today is {reference_date.strftime("%Y-%m-%d")}. Extract date range or "none".
June 2024 = 2024-06-01 to 2024-06-30
yesterday = {yesterday.strftime("%Y-%m-%d")} to {yesterday.strftime("%Y-%m-%d")}
last Saturday = {last_saturday.strftime("%Y-%m-%d")} to {last_saturday.strftime("%Y-%m-%d")}
what is the weather = none
{query} ="""
注意这里的模型选择 :即使用模型,也是 80M 参数的专用小模型(flan-t5-small),不是让通用 LLM 生成 SQL。这就是「模板替代 Text2SQL」的完整哲学------需要模型的地方用最小的模型解决确定性问题,需要 SQL 的地方用模板解决。
四、真实性能对比:472ms → 150-270ms
在恢复后的 Hindsight 0.8.0-slim 实例上,我用真实 API 测了召回延迟:
| 查询 | 第 1 次 | 第 2 次 | 第 3 次 | 平均 |
|---|---|---|---|---|
| Hindsight CRUD 实验 | 206ms | 196ms | 194ms | 199ms |
| 批量写入 async 模式 | 150ms | 142ms | 143ms | 145ms |
| test bank 增删改测试 | 228ms | 230ms | 231ms | 230ms |
| 任意不相关查询 xyzzy | 237ms | 237ms | 271ms | 248ms |
对比上一篇的 472ms:
上一篇(0.8.0 初版) 本篇(当前实例)
generate_query_embedding 84ms ┐
parallel_retrieval 352ms ├─ 语义 10ms + BM25 10ms + 图 23ms
└─ temporal extraction 305ms │ (预热后 dateparser ≈ 0)
rrf_merge 2ms ┘
reranking 3ms reranking 3ms
token_filtering 4ms token_filtering 4ms
─────────────────────────────────────────────────
总计 472ms 总计 150-270ms
速度提升主要来自三处:
load()预热把 dateparser 初始化成本从首次 recall 挪到启动期(省 ~300ms)_extract_period()规则优先拦截高频表达(不进 dateparser)- semantic+BM25 合并为单一 UNION ALL 查询,减少连接往返(上篇 9 路 → 本篇 3 路 + 图并行)
注意图检索通道还在:asyncio.gather 并行跑 graph tasks,LinkExpansionRetriever 走实体扩展。整条链路仍然是「确定性模板 + 最小模型」的组合。
五、融合层细节:RRF 不是唯一答案
5.1 cap_per_source:防止一个后端挤掉其他后端
融合之前,每个检索臂先被截断。fusion.py 的 cap_per_source() 注释说得很直白:
Applied per source (semantic, BM25, graph, temporal) before fusion so that one over-expanding backend cannot crowd out the others when the merged pool is later trimmed to the reranker's global candidate budget.
为什么需要 cap? semantic 臂过拉取 5x(500 条),graph 臂可能只有 58 条------如果不 cap,语义结果会占满 reranker 的候选预算,图通道发现的「共享实体但低词面重叠」的记忆就被挤掉了。cap 是「通道公平」的保证。
5.2 RRF 的 k=60 为什么是这个数
reciprocal_rank_fusion(result_lists, k=60)。RRF 公式:score(d) = Σ 1/(k + rank_i(d))。k 越大,排名差异带来的分数差异越小(各通道的排名更「平均」);k 越小,第一名优势越明显。60 是实践中的常用值------它在「给每个通道的 Top 排名足够权重」和「不让第一名垄断」之间取平衡。
5.3 更深的发现:interleave_fusion 是 RRF 的「去重修正」
源码里藏着一个 RRF 的已知失败模式,注释原文值得整段引用:
RRF scores a doc by the sum of its reciprocal ranks across arms, so a result that is #1 in one arm but absent/low in the others gets averaged down. That is exactly the consolidation-dedup failure mode: the near-identical existing observation (the "twin" to merge into) is semantic rank #1, yet shares no source-fact graph link and little lexical overlap, so RRF drops it below the recall budget cutoff and the LLM never sees it → creates a duplicate.
翻译成人话:consolidation 去重时,语义上最像的「孪生兄弟」记忆只在 semantic 通道排第 1,在 BM25 和 graph 通道都不出现------RRF 把它的分数平均下去了,掉出候选池,LLM 看不到它,于是产生重复记忆。这是记忆系统特有的失败模式(普通搜索不会有「去重」需求)。
Hindsight 的解法是 interleave_fusion(轮转融合):
less
取 semantic #1 → bm25 #1 → graph #1 → temporal #1
→ semantic #2 → bm25 #2 → graph #2 → temporal #2 → ...
(去重,直到全部排完)
轮转融合保证每个通道的 Top 命中都有槽位 ,语义 #1 永远第一个。它给 rrf_score 赋严格递减的位置值,所以下游按 score 排序的代码无需改动。
给你的启示:RRF 不是银弹。当你发现「多通道检索结果里,某个通道独有的高价值结果总是被平均掉」时,考虑轮转融合------它牺牲了一点全局最优,换来了通道公平性。
六、给你的工程启示
- 「模板化」不是「写死」:Hindsight 的 SQL 模板有方言抽象层、参数化、扩展点(tags_clause / extra_where)。你的业务 SQL 如果开始出现多处相似拼接,值得抽一个薄方言层------不一定要支持 Oracle,但至少把「参数 vs 字面量」的边界划清楚。
- 冷启动成本要显式管理 :任何「首次调用慢、后续快」的依赖(正则表、时区数据、模型加载)都应该在启动期预热,而不是等用户请求触发。
load()+ 假调用预热是零成本方案。 - 规则优先 + 小模型兜底:90% 的场景用正则解决(快、确定、可测试),10% 的冷门场景用 80M 小模型解决(准、慢、可接受)。这是「不用 LLM 生成 SQL」的完整版答案------不是不用模型,是不用大模型做能确定化的事。
- 解析器失败要降级:外部依赖(dateparser、分词器)会崩,崩了要降级为「无约束」而不是 500。生产系统的健壮性来自错误路径设计,不是幸运路径------把「解析失败」当成正常的业务分支来设计,而不是异常来捕获。
- 警惕窗口函数破坏索引 :
ROW_NUMBER() OVER (PARTITION BY ...)会让 PostgreSQL 规划器放弃 HNSW/部分索引走全表扫描。需要「按类型各取 Top N」时,优先考虑 UNION ALL 子查询而不是窗口函数------实测两者在数据量 600+ 时差距就达到秒级 vs 毫秒级。
七、边界与声明
- 本文 SQL/代码全部来自 Hindsight 0.8.0-slim 容器内
/app/api/hindsight_api/engine/实际源码(sql/postgresql.py、sql/base.py、query_analyzer.py、search/retrieval.py、search/fusion.py) - 性能数据为 2026-08-21 在部署 Hindsight 的服务器上真实 API 实测(内网地址,脱敏),测试 bank 使用后已清理,未污染生产数据
- 上篇结论仍成立:Hindsight 的「text2sql」= SQL 模板 + 参数注入,LLM 不参与 SQL 生成;本篇补充的是模板的分层设计和查询分析器的双轨策略
- 与上篇差异:上篇基于首次部署快照(472ms),本篇基于同一实例当前状态(150-270ms),差异来自预热与规则拦截,非版本升级