从 472ms 到 150ms:Hindsight 的 SQL 模板为何能超越 Text2SQL

预计阅读时间:21 分钟

一、回顾:上一篇的结论还成立吗

上一篇《拆开 AI 记忆引擎的 PostgreSQL 老底》的核心结论是:Hindsight 的「text2sql」不是让 LLM 写 SQL,而是硬编码模板 + 参数注入。当时拆出的读路径是:

Query → Embedding(84ms) → 4-way Parallel(352ms) → RRF(2ms) → Rerank(3ms) → Token Filter(4ms)

其中 352ms 的并行检索里,temporal extraction 占 305ms(86%)——查询根本没有时间约束,dateparser 却白白解析了 305ms。

本篇回答两个问题: 1. 那 305ms 现在去哪了?——真实环境实测已经降到 150-270ms 端到端 2. SQL 模板到底是「写死的字符串」还是「有设计的分层」?——答案是后者


二、方言抽象层:一个模板,四种全文后端

2.1 不是「写死的 SQL」,是「SQL 方言接口」

Hindsight 的检索 SQL 来自 engine/sql/base.pySQLDialect 抽象基类。先看它的方法面:

# 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.pyengine/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 长什么样,这里看它的方法签名——模板的入参就是它灵活性的来源:

# 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
# 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})"

这段代码还藏着三个真实的工程决策: 1. VectorChord 没有布尔门槛@@ 匹配是 tsvector 的布尔语义,VectorChord 却对所有行打分——所以必须显式 > min_score 过滤,否则 LIMIT 填满不相关行。这是只用过 tsvector 的人想不到的坑。 2. pgroonga 必须转义:用户记忆文本里可能含 >( 等查询语法字符,不转义会被解析成 malformed query。 3. 方言切换不需要改上层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-fetchLIMIT 500 而不是 LIMIT 100)+ Python 侧裁剪——因为 HNSW 是近似索引,ef_search=200 的召回率有损,多拉 5 倍再精确裁剪能补回损失。这两处细节解释了为什么上篇看到的「semantic 10ms 就返回」:不是数据少,是部分索引 + UNION ALL 的功劳。


三、那 305ms 去哪了:查询分析器的双轨设计

3.1 上一篇文章埋的雷

上一篇明确写了:「305ms 的 temporal extraction 对于无时间约束的查询是纯浪费」。当时我给的优化建议是「配置跳过或缓存 dateparser 结果」。Hindsight 实际选择的方案更聪明——它没有简单跳过,而是把查询分析器拆成两条轨道:

QueryAnalyzer (抽象基类)
├── DateparserQueryAnalyzer   → 规则优先,dateparser 兜底(200+ 语言)
└── TransformerQueryAnalyzer  → 规则优先,T5 小模型兜底(80M 参数)

两个实现共享同一个接口 analyze(query, reference_date) -> QueryAnalysis,但策略不同。

3.2 DateparserQueryAnalyzer:把冷启动成本挪到加载期

上篇的 305ms 来自 dateparser.search_dates() 首次调用时的惰性初始化(正则表、时区数据)。Hindsight 的解法在 load()

# 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()
        ...

关键有两处: 1. load() 预热:在引擎初始化阶段就调一次 search_dates("today"),把正则表、时区数据的初始化成本从「第一次 recall」挪到「服务启动时」。用户无感知,因为启动阶段本来就有模型加载时间。 2. _extract_period() 规则优先yesterday / today / last week / last month / last year / June 2024 等高频时间表达,用正则直接算日期范围,根本不进 dateparser。这些规则覆盖了日常查询的 90%+,且纯 CPU 微秒级。

_extract_period() 的正则还支持多语言(英西意法德):

# 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 崩了不拖垮检索

源码里还有一处容易被忽略但值得学习的容错:

# 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。这个模式对所有外部依赖都适用:解析失败 ≠ 系统失败,降级重试才是生产级设计

还有一层假阳性过滤:

# 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 等常规模式,只有规则没命中才加载模型

# 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 示例帮助小模型对齐输出格式:

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

速度提升主要来自三处: 1. load() 预热把 dateparser 初始化成本从首次 recall 挪到启动期(省 ~300ms) 2. _extract_period() 规则优先拦截高频表达(不进 dateparser) 3. semantic+BM25 合并为单一 UNION ALL 查询,减少连接往返(上篇 9 路 → 本篇 3 路 + 图并行)

注意图检索通道还在:asyncio.gather 并行跑 graph tasks,LinkExpansionRetriever 走实体扩展。整条链路仍然是「确定性模板 + 最小模型」的组合。


五、融合层细节:RRF 不是唯一答案

5.1 cap_per_source:防止一个后端挤掉其他后端

融合之前,每个检索臂先被截断。fusion.pycap_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(轮转融合):

取 semantic #1 → bm25 #1 → graph #1 → temporal #1
→ semantic #2 → bm25 #2 → graph #2 → temporal #2 → ...
(去重,直到全部排完)

轮转融合保证每个通道的 Top 命中都有槽位,语义 #1 永远第一个。它给 rrf_score 赋严格递减的位置值,所以下游按 score 排序的代码无需改动。

给你的启示:RRF 不是银弹。当你发现「多通道检索结果里,某个通道独有的高价值结果总是被平均掉」时,考虑轮转融合——它牺牲了一点全局最优,换来了通道公平性。


六、给你的工程启示

  1. 「模板化」不是「写死」:Hindsight 的 SQL 模板有方言抽象层、参数化、扩展点(tags_clause / extra_where)。你的业务 SQL 如果开始出现多处相似拼接,值得抽一个薄方言层——不一定要支持 Oracle,但至少把「参数 vs 字面量」的边界划清楚。
  2. 冷启动成本要显式管理:任何「首次调用慢、后续快」的依赖(正则表、时区数据、模型加载)都应该在启动期预热,而不是等用户请求触发。load() + 假调用预热是零成本方案。
  3. 规则优先 + 小模型兜底:90% 的场景用正则解决(快、确定、可测试),10% 的冷门场景用 80M 小模型解决(准、慢、可接受)。这是「不用 LLM 生成 SQL」的完整版答案——不是不用模型,是不用大模型做能确定化的事。
  4. 解析器失败要降级:外部依赖(dateparser、分词器)会崩,崩了要降级为「无约束」而不是 500。生产系统的健壮性来自错误路径设计,不是幸运路径——把「解析失败」当成正常的业务分支来设计,而不是异常来捕获。
  5. 警惕窗口函数破坏索引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),差异来自预热与规则拦截,非版本升级

本文由 admin 原创,转载请注明出处。

相关推荐

评论

0
暂无评论,来发表第一条评论吧

发表评论

登录 后发表评论

发现更多