AI 智能体应该用哪种语言搜索其记忆:SQL 还是 SPARQL?
2026 年 10 月 28 日 • Igor Suhorukov
智能体记忆自从智能体出现以来就一直很流行:它对抗幻觉,让 LLM 将答案和决策建立在你自己信息的基础上。究竟是从表中查询这些信息更好,还是从图中查询更好,往往是在阅读网上的帖子后凭信仰决定的,没有自己的实验和测量。在 Sequeda 等人的一项研究中,GPT-4 在企业 SQL 数据库上回答问题的正确率为 16%,而在同一数据库之上的知识图谱上为 54%;该图有一个带描述的本体,而 SQL 模式只有 DDL:类型以及表和列的名称。
在我的 GraphRAG 中,相同的信息以两种不同的形式出现。我将文章《我的 PostgreSQL 比你的快,而且几乎赶上了 ClickHouse》中的数据提取到 34 张 PostgreSQL 表中,并通过 R2RML 映射将它们转换为一个图。我通过 MCP 将两种形式的知识连接到智能体,并就这篇文章向它提出了 15 个问题:通过 SQL、通过 SPARQL,以及让智能体在两者之间自由选择。在我的实验中,智能体依次在三个开放权重 LLM 上运行。你可以在自己的基础设施或笔记本电脑上部署它们,你的数据和问题不会落入提供商或你与 LLM 之间聚合器的日志中。
我还检查并计算了哪个更适合向量存储和语义搜索:Lance 还是 pgvector。我将分享我在当前模型上关于如何最好地搜索以及在哪里存储知识的测量结果。
在实验中,我抓到 DeepSeek 编造了一个 SQL 注入来回答我的问题。
同一数据的两种表示
借助 LLM、NLP、形态学和其他吓人的词,我的数据变成了图表示和绑定到 RDF 四元组的框架。文本的词语被分解为实体、论点、测量、回复、过程等等。每个这样的片段在原文中都有其来源,遵循 W3C Web Annotation 标准。在神经网络的初始提取之上推断出一个派生数据层。总共,一篇文章在 37 个图中产生了 10,154 个四元组。
然后我将这一切放入数据库表中,这样更容易用眼睛理解。图节点变成了表行,列变成了它的属性,列表变成了行中的数组。每列的注释记录了它持有哪个 RDF 谓词。这些表上的 R2RML 映射再次组装出原始图。
记忆表的 SchemaSpy 图,包括它们的外键和行数。entities(30 行)被 events、attitudes、state_spans、narratives、claims、idea_links、quotations、processes 和 article 引用。claims(30)被 conflicts、arguments、discourse_relations、narratives 和 sections 引用。sections(7)被 evidence(317 行)、dialogues、passages、media、datasets、methods 和 code_listings 引用。quotations(17)有 speaker、stance_target、dialogue、responds_to 和 answers。datasets(16)指向 observations(49)。concepts(29)被 methods、contributions 和 concept_links 引用。processes(4)有 process_steps(29)和 process_flows(28)。
记忆还保存了文章中的过程。LLM 将它们记录为 BPMN 事件、任务和条件分支。它存储了 4 个过程,在 process_steps/process_flows 中有 29 个步骤。
BPMN 图"pg_vexec 与 MergeTree 的优化循环"。它从"有一个可工作的版本和一次 ClickBench 测量"开始,经过分析查询、根据剖析结果提出关于瓶颈的假设、原型化更改并再次测量,到达网关"更改是否加快了查询速度?"。更快:保留更改;没有更快:回滚更改。然后优化步骤完成。
BPMN 图"使用 CREATE TABLE ... USING 选择表的存储"。某个特定负载需要一张表。加载存储扩展 ch_table_engines 或 pg_inplace_update_am(文章中的清单 1)。网关"表将承载什么负载?"通向用于指标和宽表的 mergetree 存储,或通向用于热更新的 iheap 存储。然后使用标准的 CREATE TABLE ... USING 语法创建表,表就在所选存储上创建了。
BPMN 图"更新 iheap 表中的一行"。对 iheap 表一行的 UPDATE 依次运行四个服务任务:在同一 TID 处就地覆盖该行;将旧行版本移动到表区段中的 undo 日志;将旧索引条目标记为已删除,并且只向键发生变化的索引(dmbtree)添加新条目;快照需要旧版本的读取者从 undo 日志重建它。该行被更新,而表中没有新版本。
BPMN 图"在第一个条件之后读取剩余列的扫描"。扫描处理下一批:它向数据源请求第一个条件的列,并对该批应用第一个条件。如果还剩一些行,它读取该批的剩余列;如果一行都不剩,它就不读取它们。该批处理完毕。
为了检查查询是否在其他语言中也能工作,我做了一个英文翻译,并在该翻译上也运行了智能体的查询。
智能体记忆
它是一个基于 langchain4j 的 Java 程序,具有"模型 → 工具 → 模型"循环,不超过 16 次 LLM 调用和不超过 12 次记忆查询。记忆是一个单独的进程,一个基于 stdio 协议的 MCP 服务器,向 PostgreSQL 发送查询。此循环中有三个工具可用:
- sql_query 以 agent_sql 角色在 DBMS 中运行 SQL 查询。
- sparql_query 以 agent_sparql 角色在数据库中运行 SPARQL 查询。前缀 schema:、kgo: 和 oa: 已默认声明,如同许多 SPARQL 端点中一样。
- submit_answer,当智能体决定回答时,提交带有行号的引用、答案文本,或"记忆中没有这个"。
PostgreSQL 中的两个角色都只读取数据,每个查询有 5 秒超时,结果被截断为 200 行,以免溢出模型的上下文。系统提示中对记忆的描述在所有条件下都相同:表和列及其 RDF 谓词。那是 45--53 千字符,即 15--17 千 token。条件仅在工具及每个工具的简短提示上不同。
智能体如何到达其记忆的流程图。它自己在 langchain4j 上的智能体循环,最多 16 次模型调用和最多 12 次记忆查询,在 OpenRouter 上与 DeepSeek V4.1 Flash、Gemma 4 31B 和 Qwen 3.8 27B 交换消息、工具调用和 usage.cost。在 sql、sparql 或 both 条件下,它通过 stdio 调用记忆的 MCP 服务器,submit_answer 进入基于规则的检查,该检查写入包含答案、token、成本和跟踪的 JSONL。在 MCP 服务器中,sql_query 以 agent_sql 角色针对模式 habr_1097022 的 34 张表运行,sparql_query 以 agent_sparql 角色通过 R2RML 针对同一个图运行,kb.sparql_json() 读取表行。两者都是只读的,有 5 秒超时,在 PostgreSQL 19 与 pg_knowledge_base 中。
模型通过 OpenAI 协议调用。为了测试,我使用了 OpenRouter,为了不落入模型的高度量化或昂贵运行,我限制了允许的提供商并设置了中等推理强度。
java
ChatModel model = OpenAiChatModel.builder()
.baseUrl("https://openrouter.ai/api/v1")
.apiKey(System.getenv("OPENROUTER_API_KEY"))
.modelName(customModelName)
.customParameters(Map.of(
"provider", Map.of("order", List.of("deepinfra/fp8", "nebius/fp8"),
"allow_fallbacks", false, "require_parameters", true),
"reasoning", Map.of("effort", "medium")))
.returnThinking(true)
.sendThinking(true, "reasoning")
.build();
你也可以切换到自己的 vLLM/ollama/llama.cpp。
智能体通过 MCP 连接到记忆,连接到一个注册了实验所需工具的服务器:
java
McpSyncServer server = McpServer.sync(new StdioServerTransportProvider(mapper))
.serverInfo("jgraph-rag-memory", "0.1.0")
.capabilities(McpSchema.ServerCapabilities.builder().tools(false).build())
.toolCall(McpSchema.Tool.builder("sql_query", mapper, input).description(lang.sqlDescription()).build(),
(exchange, request) -> memory.call(request, true))
.toolCall(McpSchema.Tool.builder("sparql_query", mapper, input).description(lang.sparqlDescription()).build(),
(exchange, request) -> memory.call(request, false))
.build();
Properties p = new Properties();
p.setProperty("user", isSql ? "agent_sql" : "agent_sparql");
Connection c = DriverManager.getConnection(jdbcUrl, p);
c.setReadOnly(true);
try (PreparedStatement ps = c.prepareStatement("SELECT kb.sparql_json(?, ?)::text")) {
ps.setString(1, withPrefixes(query));
ps.setString(2, "https://example.org/story-kg/id/habr-1097022/");
try (ResultSet rs = ps.executeQuery()) {
rs.next();
JsonNode result = json.readTree(rs.getString(1));
}
}
智能体的主循环获得其完整历史和可用工具列表。如果智能体调用了 submit_answer,它已经完成了工作并提交了结果。在 MCP 错误时,智能体获得该错误的文本;模型看到它并可以正确地重写查询:
java
List<ChatMessage> messages = new ArrayList<>(List.of(SystemMessage.from(system), UserMessage.from(question)));
for (int turn = 0; turn < 16; turn++) {
ChatResponse response = model.chat(ChatRequest.builder()
.messages(messages).toolSpecifications(tools).build());
AiMessage ai = response.aiMessage();
messages.add(ai);
for (ToolExecutionRequest call : ai.toolExecutionRequests()) {
if (call.name().equals("submit_answer")) {
return fromArguments(call.arguments());
}
}
for (ToolExecutionRequest call : ai.toolExecutionRequests()) {
String text;
try {
text = memory.executeTool(call).resultText();
} catch (McpApplicationErrorException e) {
text = e.getMessage();
}
messages.add(ToolExecutionResultMessage.from(call, text));
}
}
OpenRouter 返回每个响应的成本以及哪个提供商提供了服务,因此本文表格中的价格是精确的,而不是根据 token 计数和价格表估算的。
以下是同一个记忆查询的示例,"作者用讽刺回答了建筑师的哪条回复?",分别用 SQL 和 SPARQL:
sql
SELECT t.id, t.text
FROM quotations q JOIN quotations t ON t.id = q.stance_target
WHERE q.stance = 'irony';
sparql
SELECT DISTINCT ?t ?text WHERE {
?q kgo:stance <https://example.org/story-kg/concept/stance/irony> ;
kgo:stanceTarget ?t .
?t schema:text ?text .
}
两者都返回原文中的回复:"那就用列式存储吧。PostgreSQL 有一打这样的东西。"
实验
为了测试记忆,我使用了七种类型的 15 个问题。每个都有金标准答案以及 SQL 和 SPARQL 的金标准查询。测试检查两个金标准查询在两种语言的数据库上是否都给出金标准答案。
| 类型 | 问题 |
|---|---|
| F1 事实 | 构建 MergeTree 需要多少个 ClickHouse 子模块? |
| F2 事实 | 哪个仓库保存 iheap 的源代码? |
| R1 关系 | 作者的建筑师朋友在生产中运行哪些 DBMS? |
| R2 关系 | 向量化执行器 pg_vexec 与哪些组件集成? |
| A1 聚合 | 在作者和他的建筑师朋友之间的争论中,每一方说了多少条回复? |
| A2 聚合 | 文章的哪一节有最多的测量集,有多少? |
| P1 路径 | 文章的哪些数值结果涉及实现 PostgreSQL 表访问方法的开发? |
| P2 路径 | 哪些主张直接或通过论证链支持"向量化需要一个返回列的数据源"这一论点?不要包括论点本身。 |
| D1 争论 | 作者用讽刺回答了建筑师的哪条回复,建筑师在其中建议了什么? |
| D2 争论 | 文章中的哪个主张受到质疑,被哪个主张质疑,它是否被驳倒? |
| D3 争论 | 在作者和他的建筑师朋友之间的争论中,每一方以自己回复结束的对话有多少? |
| C1 引用 | 建筑师朋友在文章哪里说可靠性和速度不相容?给出逐字引用(用文章自己的话,俄语)和行号。 |
| C2 引用 | 给出作者指出函数内核在查询执行时间中所占份额的逐字引用(用文章自己的话,俄语)和行号。 |
| U1 无答案 | 作者用来比较 PostgreSQL 和 ClickHouse 的笔记本电脑使用哪个处理器? |
| U2 无答案 | pg_vexec 在哪个许可证下分发? |
答案由规则检查,没有 LLM 裁判。节点集必须完全匹配,多余的节点算作错误。数字与金标准答案比较。引用必须逐字出现在文章指定的行上。对于没有答案的问题,只有"记忆中没有这个"才算正确。
条件是 sql、sparql 和 both;在最后一种中,智能体拥有两个工具。后来我添加了一个对照条件。
我选择了三个开放权重模型:DeepSeek V4.1 Flash、Gemma 4 31B 和 Qwen 3.8 27B。你可以在本地运行它们或将它们部署在自己的服务器上。智能体的记忆通常包含机密信息,将其发送到外部 API 不是个好主意。不过,在实验中,我通过 OpenRouter 调用模型:在我的笔记本电脑上,即使 4 位量化的 27B 模型,我也无法在合理时间内运行。
模型端点和价格
每个组合重复三次:3 个模型 × 3 个条件 × 15 个问题 × 3 次重复,数据库每种语言 405 次运行。运行并行进行,每个模型和条件 8 个,全部 405 次花了 10 分钟。
起初,严格检查更多地抓住了答案格式。你问什么支持一个论点,模型把论点本身也加到答案里。你让它计算每个参与者的回复数,它按对话计数。所以答案得到了一个上下文字段,它不被检查但帮助这些模型做得更好。模型还忘记在 SPARQL 中声明前缀,所以我添加了默认前缀。经过所有这些修复后,一次试运行在 27 个中给出了 26 个正确答案。
结果
在 ru/en 数据数据库上的准确率:
| 模型 | RU sql | RU sparql | RU both | EN sql | EN sparql | EN both |
|---|---|---|---|---|---|---|
| DeepSeek V4.1 Flash | 45/45 | 45/45 | 45/45 | 45/45 | 45/45 | 45/45 |
| Gemma 4 31B | 42/45 | 44/45 | 43/45 | 45/45 | 44/45 | 44/45 |
| Qwen 3.8 27B | 45/45 | 44/45 | 45/45 | 43/45 | 45/45 | 43/45 |
| 总计 | 97.8% | 98.5% | 98.5% | 98.5% | 99.3% | 97.8% |
DeepSeek 在全部 270 次中都回答正确。在 Gemma 和 Qwen 的 13 个错误中,有 12 个落在两个问题上:P1 和 D3。
成本和速度:
| 模型 | 每次运行成本,RU / EN | 每个正确答案成本,RU | 中位数,秒,RU / EN | 每次运行输入 token | 其中缓存 |
|---|---|---|---|---|---|
| DeepSeek | 0.0009 / 0.0009 | 0.0009 | 13 / 12 | 81K | 97% |
| Qwen | 0.0077 / 0.0104 | 0.0077 | 38 / 50 | 83K | 87% |
| Gemma | 0.0234 / 0.0225 | 0.0245 | 57 / 61 | 114K | 0% |
每次运行,智能体平均调用模型 4--7 次,每次重新发送 15--17 千 token 的提示。如果提供商缓存提示会有帮助。例如,DeepInfra 缓存 DeepSeek 而不缓存 Gemma,在相同答案下 Gemma 的成本高 25--27 倍。
在相同准确率下,SPARQL 对我来说比 SQL 更贵。它每次调用有 2--3 倍的数据库错误:俄语数据库上 5.9% 对 2.8%,英语数据库上 7.3% 对 2.5%。三个模型的运行成本都增长:
| 模型 | RU:sql → sparql,每次运行成本 | EN:sql → sparql,每次运行成本 |
|---|---|---|
| DeepSeek | 0.0008 → 0.0010(+25%) | 0.0009 → 0.0010(+11%) |
| Gemma | 0.0218 → 0.0234(+7%) | 0.0212 → 0.0234(+10%) |
| Qwen | 0.0063 → 0.0103(+63%) | 0.0064 → 0.0157(+145%) |
智能体能很好地自我纠正:它们读取错误文本并重写查询。在数据库返回错误的 134 次运行中,有 129 次以正确答案结束。
在两个工具都可用的情况下,三个模型在每次运行中都从 SQL 开始。顺序无关紧要:在对照条件 both_sparql_first 中,SPARQL 在所有地方都排在前面:在工具列表中、在提示中关于它们的句子中以及在提示的顺序中。SQL 仍然在 135 次运行中的 131 次领先。
| 条件 | 先 SQL | SPARQL 调用 | 使用 SPARQL 的运行 | 正确 |
|---|---|---|---|---|
| both, RU | 135/135 | 17/691 | 12 | 133/135 |
| both, EN | 135/135 | 25/661 | 16 | 132/135 |
| both_sparql_first, RU | 131/135 | 23/660 | 16 | 131/135 |
模型在不知道去哪里找时会求助于 SPARQL。最常发生在关于 pg_vexec 许可证的问题上,文章没有提到它。模式没有暗示哪张表会持有许可证,而图让你可以在一个查询中看到节点的所有属性:SELECT ?p ?o WHERE { <pg_vexec> ?p ?o }。DeepSeek 正是这样推理的:"通过 SPARQL 在所有字面量中搜索'лиценз'"("лиценз"是俄语"许可证"的词干)。
所有模型调用都通过 OpenRouter:1,389 次运行花费 12.80。在主要运行花费的 10.68 中,70% 花在 Gemma 上,7.46,0.28 花在 DeepSeek 上。
按运行集划分的成本和时间
大多数 SPARQL 错误是试图把它写成 SQL:用 LOWER() 而不是 LCASE(),SELECT COUNT(?d) 而没有 (COUNT(?d) AS ?n),变量上的语言标签(schema:name ?name@ru)。在 SQL 中,模型猜测不存在的列(article.id 而不是 slug),用 UNION 将列数不同的结果集粘在一起,将数组与字符串比较并得到 text\[\] = text。
在英语数据库上,Qwen 三次答错 D3。有两次,它将"每一方是否以自己的回复结束"理解为"一方回复了自己":例如,它只把作者既开始又结束的对话归给作者。第三次,文本中的总数是对的,但答案的节点包括了每个对话的行。俄语措辞"закончил своей репликой"("以自己的回复结束")没有引起这种混淆。用"每一方是否拥有最后发言权"的措辞,全部 27 次运行都给出了正确答案,Qwen 是 9/9 而不是 6/9。我是在看到结果后才更改问题的,所以上面的主表保留了旧措辞。
在没有答案的问题上,所有条件下的全部 126 次运行都回答"记忆中没有这个"。没有模型编造笔记本电脑的处理器或许可证。但在那之前,智能体会遍历整个记忆:中位数为 12 次记忆查询,而其他问题上为 3 次。在每个数据库上,59 次运行达到了 12 次查询的限制,而且全部都是在两个无答案问题和 P1 上。
普通 RAG 与图 RAG 对比
当知识已经按字段排列好时,图和表很好。更常见的是,智能体的记忆是一堆文本,按嵌入进行语义搜索更容易。
我从我在浏览器中按意义和字段的搜索(俄语)中取了语料库:13,275 篇 Habr 文章,几乎都是 2023 年的。对于每篇文章,LLM 提取了摘要、意义、主题、结论、体裁、情感,以及煽动性和广告的分数,EmbeddingGemma 300M 计算了这些文本的向量:147,432 个维度为 768 的向量。所有这些都放在 GitHub Pages 上的 parquet 文件中。
我直接用 DuckDB 2.0 通过其 JDBC 驱动从该存储加载它。DuckDB 通过 HTTPS 直接读取 parquet 文件,COPY 将数据移入 PostgreSQL。
加载代码:DuckDB JDBC 和 COPY
在嵌入旁边,表保存了计算它们所依据的文本;对于一个主题,那是其名称和描述用空格分隔。智能体的查询由 ollama 用相同的模型嵌入,对于语料库文本它给出相同的向量:余弦相似度高于 0.999。
pgvector 还是 Lance
在 PostgreSQL 中存储向量至少有两种方式。第一种是普通表加 pgvector,带 HNSW 或 IVFFlat 索引。我最近添加了第二种:来自 pg_lance_tam 的 USING lance 表。它的行采用 Lance 的列式格式,而 PostgreSQL 将其视为支持事务和复制的普通表。这样的表有自己的索引:向量索引 IVF_HNSW_SQ 和 IVF_PQ,以及标量 BTREE 和 BITMAP。
Lance 的主要区别在于过滤器。如果条件在同一表的列上,它在向量搜索之前应用条件。这就是为什么我在每一行带向量的记录中重复了搜索要过滤的文章字段:体裁、情感、日期、评分、发布类型。
Lance 查询计划:在搜索之前过滤
pgvector 的 HNSW 做相反的事:它从图中取 ef_search = 40 个最近邻,然后才丢弃不匹配的。在负面情感过滤器下(4% 的行),十个结果中只剩下一个。
pgvector 查询计划:在搜索之后过滤
我在 Testcontainers 测试中在相同的行上运行了比较:普通表和 Lance 表中各 147,432 个向量,20 个查询,11 个过滤器,从"无过滤器"一直到 252 行。对于每次搜索,测试测量时间和找到的精确十个最近向量的份额(recall@10)。
| 。 | pgvector(堆) | Lance |
|---|---|---|
| 带向量的行 | 677 MB | 453 MB |
| 向量索引 | HNSW:17--31 秒,575 MB | IVF_HNSW_SQ,16 个分区:2.3--2.8 秒,136 MB |
| 带粗分区的索引 | IVFFlat,150 个列表:2 秒,577 MB | IVF_PQ:12 秒,9 MB |
构建在 4 个并行工作器上运行,maintenance_work_mem 为 2 GB,经过三次测试运行。
搜索十个最近邻,20 个查询的中位数,以及 recall@10:
| 。 | 无过滤器 | summary(13K 行) | opinion(17K) | negative(6K) | fiction(252) | hub,一个数组(1.7K) |
|---|---|---|---|---|---|---|
| pgvector HNSW | 0.3 ms · 0.95 | 0.3 ms · 0.27 | 0.3 ms · 0.31 | 0.3 ms · 0.09 | 0.7 ms · 1.00 | 0.3 ms · 0.11 |
| pgvector HNSW,迭代扫描 | 0.4 ms · 0.95 | 1.9 ms · 0.98 | 1.5 ms · 0.96 | 7.6 ms · 0.95 | 0.7 ms · 1.00 | 15.5 ms · 0.89 |
| Lance IVF_HNSW_SQ | 1.1 ms · 0.94 | 1.5 ms · 0.99 | 1.4 ms · 0.97 | 1.4 ms · 0.95 | 1.2 ms · 0.94 | 3.1 ms · 0.97 |
| Lance,所有分区,细化 10 | 2.5 ms · 1.00 | 3.7 ms · 1.00 | 4.8 ms · 1.00 | 3.8 ms · 1.00 | 2.3 ms · 1.00 | 4.3 ms · 1.00 |
简而言之:
- 在没有过滤器的查询上,pgvector 更快。HNSW 在 0.3 ms 内回答,Lance 在 1--2.5 ms 内:准备 Lance 扫描大约需要一毫秒。
- 有过滤器时,HNSW 会丢失结果。在 summary 字段的过滤器下,它找到 27% 的精确邻居,negative 下为 9%,有些查询返回零行而不是十行。将 ef_search 提高到 200 也不能挽救它。pgvector 0.8 的迭代扫描有帮助:召回率 0.89--0.98,但搜索变得更昂贵,7--15 ms,p95 达到 25 ms。在最稀有的过滤器(NSFW、fiction)上,计划器自己从 HNSW 转向 B-tree 和精确排序。
- Lance 总是返回所要求的。过滤器已经应用,搜索只在匹配的向量中进行。探测索引的全部 16 个分区并用完整向量细化(lance.nprobes = 16,lance.refine_factor = 10),在每个过滤器上召回率都是 1.00,包括 hubs 数组,耗时 2--6 ms。智能体也使用这些设置搜索。
- 列式格式也加速了精确搜索。没有向量索引,在中等选择性的过滤器(opinion、negative)上,Lance 比堆上的精确搜索快 2--2.5 倍找到十个最近邻:它只读取需要的列。没有过滤器时,它慢 15%。
- 起初,Lance 不在数组上过滤。条件 hubs @> ARRAY'PostgreSQL' 留在 PostgreSQL:Lance 取 100 个最近向量,PostgreSQL 过滤它们,召回率降到 0.16,有时为零行。我向 pg_lance_tam 添加了将 @>、&& 和 = ANY 转换为 Lance 的函数 array_has_all、array_has_any 和 array_has,由 LABEL_LIST 索引服务。现在这个过滤器也在搜索之前应用:在智能体的设置下,召回率为 1.00,耗时 4.3 ms,而没有向量索引的精确搜索需要 2.1 ms 而不是 134.5。
- 固定的 nprobes = 4 比 Lance 默认执行的自适应分区选择更差:召回率 0.39--0.64,在稀有过滤器下有时少于十行。IVF_PQ 将索引压缩到 9 MB,但即使细化,召回率也只有 0.39--0.87。
- Lance 在大小上也赢:表小三分之一,IVF_HNSW_SQ 索引比 HNSW 小 4 倍,构建快 7--11 倍。
对于语料库上的向量搜索,智能体得到了工具 semantic_search(query, where, k)。MCP 服务器通过 ollama 用相同的嵌入模型计算查询的向量并运行查询:
sql
SELECT task_id, habr_id, title, embedding_name, index, text, embedding_vector <=> $1 AS distance
FROM text_embeddings
WHERE <the agent's condition>
ORDER BY embedding_vector <=> $1
LIMIT k;
条件是:corpus_sql,仅对语料库表使用 SQL;vector,仅按意义搜索;vector_sql,两个工具都用。共有 12 个问题:五个按意义关于文章("作者在哪篇文章中建造了一辆脚踏车,沿着贝加尔湖旁的旧铁路骑行?"),三个关于集合的结构(计数、浏览最多的文章),两个关于按意义找到的文章的字段,以及两个记忆没有答案的。DeepSeek V4.1 Flash 回答,三次重复:108 次运行花费 $0.13。
| 问题 | corpus_sql | vector | vector_sql |
|---|---|---|---|
| 按意义 | 14/15 | 15/15 | 15/15 |
| 关于集合的结构 | 6/9 | 4/9 | 8/9 |
| 按意义找到的文章的字段 | 5/6 | 0/6 | 6/6 |
| 无答案 | 6/6 | 6/6 | 6/6 |
| 总计 | 31/36 | 25/36 | 35/36 |
| 每次运行成本 | 0.00063 | 0.0024 | 0.00055 |
| 中位数,秒 | 15 | 28 | 12 |
按意义,向量搜索没有犯一个错误。SQL 也按词语找到文章,并在一个可争议的案例上犯了错误:智能体找到了关于 GoDaddy 的新闻,但推理说 GoDaddy 是域名注册商,不是托管提供商,并在答案中只留下了 Hetzner。
局限性。搜索返回 k 个文本,而不是聚合。没有 SQL 的智能体仍然数了小说文章:它按体裁和 summary 字段过滤,每篇文章一行,数了 23 行。但在关于找到的文章的浏览量的问题上,它诚实地回答"记忆中没有这个"。在关于浏览最多的文章的问题上,它三次说错了:两次是"Итоги 2022 года на Хабре"(Habr 的 2022 年回顾):搜索没有 views 列,智能体按意义选择。
使用两个工具时,智能体犯了一个错误,而且是格式上的错误。纯向量搜索的成本高四倍:智能体不断尝试查询,而每个搜索结果都是对话历史中的 10--50 个文本。
DeepSeek 编造了一个 SQL 注入来回答一个问题
当然,原型是拼凑起来的,正如原型应该的那样,它的 semantic_search 工具有一个 where 参数,智能体可以在其中传入 SQL 过滤条件。DeepSeek V4.1 Flash 没有错过使用它的机会。首先用一个子查询:
sql
task_id IN (SELECT task_id FROM indexed_text ORDER BY reading_count DESC LIMIT 1)
当它需要文章的实际阅读数时,它构建了一个真正的 SQL 注入。
sql
habr_id = 757486 AND (SELECT ('views=' || reading_count)::int FROM indexed_text WHERE habr_id = 757486) > 0
它从错误文本中读取了浏览量。
ERROR: invalid input syntax for type integer: "views=5623"
这是一个陷阱问题,模型不可能有确切答案。它就是在那里被抓住的!LLM 罪行的记录保存在日志中;搜索 'views='。我用一个单独的角色修补了工具中的漏洞,在该角色中这些都不可访问,并用测试覆盖了它,这样构建注入就不会有回报。
要点
我原以为 SPARQL 会赢,但事实并非如此。SQL 赢了:所有模型都更频繁地选择它。模型在 SPARQL 查询中犯了 2--3 倍的错误,在一种情况下,SPARQL 的美元成本是 SQL 的 1.6--2.5 倍。在两个工具都可选择的情况下,模型仍然在 135 次运行中的 135 次偏好 SQL,即使 SPARQL 在可用工具列表中排在前面。
整个实验在 OpenRouter 上的智能体运行花费我 $12.80。在处理结果后,我的结论是:
- 一个设计良好的模式,带有表和列的注释,就足以作为智能体的记忆。
- 你不必为知识图谱设置单独的数据库。PostgreSQL 表上的 R2RML 通过 pg_knowledge_base 将它们变成用于 SPARQL 查询的图,你不需要单独的四元组存储:一切都与普通表存储在同一个地方,支持事务和复制。
- 所有模型都适合回答问题和搜索记忆。DeepSeek V4.1 Flash 正确回答了全部 270 个问题,Qwen 3.8 27B 98%,Gemma 4 31B 97%。这些是你可以在本地部署的模型!我也试了 Sonnet 5.5:它花费 considerably 更多,在原型中没有特别优势。
- 对于带字段过滤器的搜索,Lance 比 pgvector 更方便。Lance 在搜索之前过滤,总是返回请求的 k 行,而 pgvector HNSW 在没有迭代扫描时会在过滤器下丢失结果。Lance 表小三分之一,其索引紧凑 4 倍,构建快 7--11 倍。没有额外过滤器时,pgvector 更快。
- PostgreSQL 表的 SPARQL 引擎在 pg_knowledge_base 中可用,Lance 表和索引在这里:pg_lance_tam。