注册
用DM9当向量数据库,给自己建个RAG知识库【#达梦数据库 #达梦同行者征文】
专栏/技术分享/ 文章详情 /

用DM9当向量数据库,给自己建个RAG知识库【#达梦数据库 #达梦同行者征文】

M4x7 2026/09/11 27 0 0
摘要

1. 摘要

使用达梦9作为向量数据库,我实现了一个完整的个人 RAG 知识库。

在实现完整功能的基础上,为了让检索更准,我做了四轮实测升级:

  • 按结构分块;
  • 全文 + 向量混合检索双引擎;
  • 向量索引近似检索;
  • INT8 量化存储减半。

实践证明,达梦9 具备开箱即用的向量检索能力——VECTOR 类型、距离函数、向量索引、全文索引全是标准 SQL,完全可以作为 RAG 应用的向量数据库选择。

(本项目已开源:https://github.com/a-lost-horse/dmrag)

2. 出发点

为什么做? 学习数据库这些日子,攒了不少运维笔记,一直用笔记软件维护。但是万一想不起或记错了关键词,就怎么也搜不到了。此外,官方的文档也得老老实实全文搜索,大海捞针。我希望:输入一句话,就能从笔记中找到相关的结果。

怎么实现? 其实成熟的RAG框架不少,如LangChain、LlamaIndex。但在这里,我想搞懂每一环,把除了embedding模型的每一步都手动实现。正好趁着dm9推出向量数据库,手写一个小型的RAG知识库。麻雀虽小,五脏俱全,满足一下自己小小的成就感。

3. RAG 原理

RAG(检索增强生成)解决的问题是:大模型的知识有截止日期、还容易幻觉,与其让它"凭记忆答题",不如让它"带着资料答题"。资料从哪来?知识库。怎么把"对的那几页"找出来?核心是把文本变成向量,用距离度量相似度:

image.png

后面两章就是这条流水线的完整实现:先写一个最小的能跑版本(第 4 章),再追求细节提升检索效果(第 5 章)。话不多说,先实现一个最简单的版本,边看代码边了解。

4. 基础版本

先交代语料:我的知识库由两份资料组成——**达梦官方《DM8 管理员手册》**和 我 Obsidian 里的 95 篇运维笔记(建库、备份、慢 SQL 排查这些总结文档)。

4.1 数据分块

数据分块是为了让检索"找得准":把整篇文档揉成一个向量,检索只能定位到"篇";切成小块后,每个块独立向量化,命中哪块就返回哪块,检索粒度从"篇"细化到了"段"。

伪代码为:

读取全文
若全文长度 ≤ CHUNK_SIZE(300 字符):
    整篇就是一个文本块
否则:
    从第 0 个字符开始,每隔 CHUNK_SIZE 个字符切一刀,切成若干等长文本块
给每个块编号(0、1、2、...),与文件名一起登记。

汇总所有文件的分块结果 → [(文件名, 块号, 文本内容), ...]

关键代码:

CHUNK_SIZE = 300 # 单块最大字符数 chunks = [] # 汇总所有文件的分块结果 if len(text) <= CHUNK_SIZE: # 全文长度 ≤ 300 字符:整篇就是一个文本块 pieces = [text] else: # 全文长度 > 300 字符:每隔 300 字符切一刀 pieces = [text[s:s + CHUNK_SIZE] for s in range(0, len(text), CHUNK_SIZE)] for i, piece in enumerate(pieces): # 给每个块编号,与文件名一起登记 chunks.append((path.name, i, piece))

关键点:
那么这里的CHUNK_SIZE为什么是300个字符呢?因为模型按 token(词元)计数,我们使用的小模型 bge-small-zh-v1.5 单次最多能编码 512 个 token。300 个字符 ≈ 350 token,再保留一定空间避免超限。
超限的后果不是报错,是静默截断:模型只编码前 512 个 token,块尾的内容直接被丢弃,而块尾可能正是答案所在。所以300 是保证块完整的一个安全数值。

4.2 向量化

分块完成,接下来要把每一块文本变成计算机能计算的向量——一串数字,让语义相近的文本在向量空间里距离也近。这一步由本地语义模型完成。

伪代码为:

加载本地语义模型 bge-small-zh-v1.5(首次约 10~30 秒,之后进程内复用)
把刚刚切分出的全部文本块一次性交给模型批量编码
    → 每个文本块得到一个 512 维的向量

关键代码:

MODEL_NAME = "BAAI/bge-small-zh-v1.5" # 本地语义模型,512 维,约 100MB model = SentenceTransformer(MODEL_NAME) # 加载模型(进程内只加载一次) vecs = model.encode(texts, normalize_embeddings=True) # 全部文本块一次批量编码

encode 的输入是文本列表,输出是一个 (文本数, 512) 的向量数组——每个文本块对应一行 512 维向量。

关键点:

介绍一下我们用的模型。bge-small-zh-v1.5 是 BAAI 开源的中文语义嵌入模型,基于 BERT 架构的压缩版——512 维输出、约 100MB,普通 CPU 就能跑。它是靠对比学习训练出来的:把海量"(问题,相关文档)"配对喂给模型,训练目标是把相关文本的向量拉近、不相关的推远。所以训练出来的模型天生就适合检索——这正是我们把它用在 RAG 里的原因。

顺带一提:"文本→向量"的能力只能外接。达梦9 负责向量的存储与距离计算,但数据库本身不做文本转向量的工作。嵌入模型跑在外部,生成向量后再交给达梦。

4.3 建表与入库

向量算好了,得找个地方存。达梦9 原生支持 VECTOR 类型——一张表存下知识库的全部:原文和向量放一起,检索时还能在 SQL 里直接算距离。

伪代码为:

连接达梦
建表 doc_chunks_simple:
    id 自增主键 / file_name 来源文件名 / chunk_index 块号
    content 分块原文 / vec VECTOR(512) 向量列
把 [(文件名, 块号, 原文, 向量字符串), ...] 组装成插入参数
executemany 一次批量插入全部行

关键代码——两条 SQL 就是全部核心:

-- 1. 建表:核心是 vec VECTOR(512, FLOAT32) 这一列 CREATE TABLE doc_chunks_simple ( id INT IDENTITY, -- 自增主键 file_name VARCHAR(255), -- 来源文件名 chunk_index INT, -- 文件内块号(从 0 开始) content TEXT, -- 分块原文 vec VECTOR(512, FLOAT32), -- 512 维向量 PRIMARY KEY (id) ); -- 2. 插入:向量以字符串形式绑定,由 TO_VECTOR 显式转换成向量 INSERT INTO doc_chunks_simple(file_name, chunk_index, content, vec) VALUES (?, ?, ?, TO_VECTOR(?, 512, FLOAT32));

Python 侧只是把 4.2 的向量字符串填进 ? 占位符、批量执行:

TABLE = "doc_chunks_simple" # 与上面 SQL 的表名一致 rows = [(fn, idx, content, vec_str) for (fn, idx, content), vec_str in zip(chunks, vec_strs)] cur.executemany( f"INSERT INTO {TABLE}(file_name, chunk_index, content, vec) " f"VALUES (?, ?, ?, TO_VECTOR(?, 512, FLOAT32))", rows)

关键点一:
为什么 vec 列要定义成 VECTOR(512, FLOAT32),512 是哪儿来的?向量维度由嵌入模型决定——bge-small 的输出就是 512 维,建表必须跟模型对齐,写错维度达梦会直接报错。

关键点二:
为什么 4.2 编码出的向量要拼成 [0.1,0.2,...] 字符串、再由 TO_VECTOR 转回来?因为 dmPython 裸接口没有专门绑定向量类型的参数,达梦规定向量以这种字符串字面量传入——vector_to_str 把向量转成文本,就是干这个的。不过要说明:达梦的 dmSQLAlchemy 方言包其实提供了向量类型,可以绕开字符串直接传列表。用方言包的完整建表和插入是这样(需要 pip install dmSQLAlchemy):

from sqlalchemy import create_engine, Column, Integer, Identity, String from sqlalchemy.orm import declarative_base, Session from dmSQLAlchemy import VECTOR # 方言包提供的向量类型 # 1. 连接达梦(URL 写法:dm+dmPython://用户:密码@主机:端口) engine = create_engine("dm+dmPython://SYSDBA:***@192.168.1.48:5236") Base = declarative_base() # 2. 用 ORM 模型声明表结构:向量列直接用 VECTOR 类型,dim/format 写在类型里 class ChunkModel(Base): __tablename__ = "doc_chunks_orm" id = Column(Integer, Identity(), primary_key=True) file_name = Column(String(255)) embedding = Column(VECTOR(dim=512, format="float32")) Base.metadata.create_all(engine) # 3. 按模型自动建表 # 4. 插入:向量直接传 Python list——不用拼字符串,不用写 TO_VECTOR session = Session(engine) session.add(ChunkModel(file_name="示例.txt", embedding=[0.1, 0.2, ...])) # ... 表示 512 个数 session.commit()

我们这里刻意不用方言包、手写字符串和 TO_VECTOR,是为了学习使用最底层的 SQL 语法。

4.4 检索

到此为止,分块、向量化、入库都完成了。最后一步:用户输入一句话,返回知识库里最相关的 5 块原文——整个 RAG 的出口。

伪代码为:

循环:
    读取用户输入的问题(空行退出)
    问题文本 → 模型编码成向量
    到达梦:按余弦距离升序取前 5 行
    逐行打印:排名 / 相似度 / 来源文件 / 块号 / 原文

关键代码——SQL 单独拉出来定义成常量,方便讲解与复用(执行时直接引用,不用把大段 SQL 塞进 execute 里):

-- 检索 SQL:按「与查询向量的余弦距离」升序取前 TOP_K 行(距离越小越相似) -- ? 是查询向量的占位符,在 SELECT 和 ORDER BY 里各出现一次 → 执行时传两份 SELECT file_name, chunk_index, content, COSINE_DISTANCE(vec, TO_VECTOR(?, 512, FLOAT32)) AS dist FROM doc_chunks_simple ORDER BY COSINE_DISTANCE(vec, TO_VECTOR(?, 512, FLOAT32)) FETCH FIRST 5 ROWS ONLY;

Python 侧只是把问题向量填进占位符执行:

vec = model.encode(QUERY_PREFIX + query, normalize_embeddings=True) # 查询加前缀! vec_str = vector_to_str(vec) # 同上 4.3 的字符串传参 cur.execute(QUERY_SQL, (vec_str, vec_str)) # ? 出现两次 → 传两份

关键点:

为什么查询的文本要加 QUERY_PREFIX(“为这个句子生成表示以用于检索相关文章”)?因为 bge 模型训练时就区分两类文本:查询句统一带着这句指令、文档句不带。模型学到的是"带前缀 → 按查询的方式编码,裸文本 → 按文档的方式编码"。查询如果不加前缀,向量会"走错位置",和库里按文档方式编码的向量对不齐,命中率就下降了。所以 4.2 入库时文档不加前缀,这里查询加前缀——两处 encode 唯一的区别就在这一行。

顺带说清楚 SQL 里的 COSINE_DISTANCE:它是达梦提供的距离计算函数之一——返回的是"距离"而非"相似度":完全相同为 0,越相似越小,所以打印时用 1 - dist 换算成相似度(越大越相关)更直观。达梦的距离函数不止这一个,常用的有:

函数 含义 直觉
COSINE_DISTANCE 余弦距离 = 1 − 两向量夹角的余弦 只看方向,不看长度;语义检索首选
L2_DISTANCE 欧氏距离 = 两点直线距离 受向量长度影响;适合几何数值向量
L1_DISTANCE 曼哈顿距离 = 各维差绝对值之和 稀疏向量友好
INNER_PRODUCT 内积(点积) 归一化向量(模长=1)时与余弦等价

RAG 为什么用余弦?因为文本向量的长度本身没有语义(同一句话的向量模长随编码方式变化),语义只体现在方向上——余弦恰好只看方向夹角,天然适合"语义相似度";而欧氏距离会被向量长度干扰。这也是 4.2 里 encodenormalize_embeddings=True 的原因:把向量归一化成单位向量,让余弦和内积也一致起来。达梦还提供统一入口 VECTOR_DISTANCE(vec1, vec2, metric),metric 可以指定上面任意一种。

到这里,基础版 RAG 的五个大步骤全部跑通:建表 → 分块 → 向量化 → 入库 → 检索。整个流程只有一个文件、四段核心代码;想亲手跑一遍的话,上面这些代码已经足够——它们就是 simple/rag.py 的全部。

最后是 main——把前面各节的函数按顺序串起来,整个基础版就完整了:

def main(): force = "--force" in sys.argv # --force = 重建表并重新入库 conn = get_conn() try: init_table(conn, force) # 步骤 1:建表(--force 时删旧表重建) if force: # 只有 --force 才重新入库 chunks = chunk_all() # 步骤 2:分块(4.1) vec_strs = embed_all(chunks) # 步骤 3:向量化(4.2) store_all(conn, chunks, vec_strs) # 步骤 4:建表入库(4.3) search_loop(conn) # 步骤 5:检索(4.4) finally: conn.close() if __name__ == "__main__": main()

不加 --force 直接跑,就跳过入库、直接用库里的数据检索——这就是 main 里 if force 的意义。

实际使用就是这样一段交互(库已就绪,python .\simple\rag.py 直接检索):

python .\simple\rag.py
表 doc_chunks_simple 已存在,直接使用(--force 可重建并重新入库)。
正在加载模型 BAAI/bge-small-zh-v1.5 ...
Loading weights: 100%|███████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 71/71 [00:00<00:00, 2730.22it/s]
模型加载完成。

请输入查询(空行退出):怎么修改事务隔离级别

第 1 名 | 相似度 0.682 | 第19-管理事务.txt 块50
空间。
## 19.7 事务隔离级
在关系型数据库中,事务的隔离性分为四个隔离级别,在解读这四个级别前先介绍几个关于读数据的概念。
1. **脏读(DirtyRead)**
所谓脏读就是对脏数据的读取,而脏数据所指的就是未提交的已修改数据。也就是说,一个事务正在对一条记录做修改,在这个事务完成并提交之前,这条数据是处于待定状态的(可能提交也可能回滚),这时,第二个事务来读取这条没有提交的数据,并据……

第 2 名 | 相似度 0.677 | 第19-管理事务.txt 块59
提交隔离级别是最不严格的隔离级别。实际上,在使用这个隔离级别时,有可能发生脏读、不可重复读和幻读。一般来说,读未提交隔离级别通常只用于访问只读表和只读视
第 19 章 管理事务
图,以消除可见性判断带来的系统开销,提升查询性能。
用户可以在事务开始前使用以下语句将后续开始的第一个事务设置为读未提交隔离级:
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITT……

第 3 名 | 相似度 0.676 | 第19-管理事务.txt 块56
求,并最大限度的保证系统并发性能,但可能会出现不可重复读取和幻读。
用户可以在事务开始前使用以下语句将后续开始的第一个事务设置为读提交隔离级:
<mark>SET TRANSACTION ISOLATION LEVEL READ COMMITTED;</mark>
SET TRANSACTION ISOLATION LEVEL 语句仅作用于该语句执行完成后开始第一个事务,如果不使用该语句,则事务默……

第 4 名 | 相似度 0.640 | 第19-管理事务.txt 块58

用户可以在事务开始前使用以下语句将后续开始的第一个事务设置为串行化隔离级:
<mark>SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;</mark>
SET TRANSACTION ISOLATION LEVEL 语句仅作用于该语句执行完成后开始第一个事务,如果不使用该语句,则事务默认采用 INI 参数 ISOLATION_LEVEL 指定的隔离级,该……

第 5 名 | 相似度 0.635 | 第19-管理事务.txt 块7
必须与任何其他的并发事务所做的修改相互隔离。这样,只有当某个值被一个事务修改完并提交后才会影响到另一个事务。事务只会识别另一并发事务修改之前或者修改完成之后的数据,不会识别处于这中间状态的数据。事务的隔离行为依赖于指定的隔离级别。隔离级别的介绍请参考 19.7 事务隔离级。
### 19.2.4 持久性
持久性是指一个事务一旦被提交,它对数据库中数据的改变就是永久性的,接下来的其他操作和数据库故障……

整个基础版本,到此完整可运行。

5. 进阶实现

基础版跑通了——分块、向量化、入库、检索,一个文件全搞定。但"能跑"和"好用"之间,隔着不少细节。这一章讲我给系统做的每一步升级:改了什么、为什么改、底层原理是什么,每一步都附上数据库里的实测对比,让提升看得见。

先交代语料口径:这一章所有实验都在同一份知识库上进行——达梦官网《DM8 管理员手册》的 28 个文档(25 章正文 + 附录 1~3:数据字典/动态性能视图/V 视图)+ 我 Obsidian 里的 95 篇运维笔记(共 3628 个文本块,无其他杂质)。

官网文档是抓取来的,入库前做了三道清洗:剥掉 Obsidian 属性头(frontmatter)、还原参数名的转义(官网写作 SORT\_BUF\_GLOBAL\_SIZE 防斜体,不还原就检索不到)、把个别 HTML 格式的表格转成文本表。清洗不是锦上添花——不还原转义,下面的"SORT_BUF_GLOBAL_SIZE 单位"这类问题永远没有答案。

5.1 分块升级:从"整段盲切"到"结构感知"

改了什么:基础版用固定 300 字滑动窗口切块,切点对内容一视同仁。进阶版换成结构感知分块——先识别文档的 Markdown 结构,再决定怎么切:

  • 表格、代码块作为整体保留,绝不拦腰切断;
  • 按标题层级分节,每一块开头带上它的"标题路径"(如 # 第2章 > ## 2.3 > ### 3. 列存储数据);
  • 超长表格按行拆成多片,但每片都重复表头和表格标题,拆完仍是一张"有身份"的完整表格;
  • 标题路径超过 3 级时截断(第 2 章有 2.1.1.1.2.1 这种六层深标题,全保留会挤占内容空间)。

为什么这么改:固定切块对"文字段落"还行,遇到表格和代码就出问题——300 字一刀下去,表格可能被切成半截行,代码块被切得七零八落。这些"残片"向量化之后就成了噪声:检索时它们既不像表格也不像代码,语义上是碎渣。而检索的最小单元是块,块的语义越完整,命中越有意义。块首的标题路径则是给残块"补上下文"——一小段数据脱离了标题,模型不知道它在讲什么;带上 ## 2.3 数据文件,整块立刻有了归属。

数据库实测对比:在"固定分块表"与"结构分块表"(同一份手册/附录语料)上跑 15 条手册细节题(参数默认值与取值范围、SQL 语法、命令写法,每条答案精确到一个块),两版各自检索,判定标准:人工标注的理想答案块出现在系统前 10 名内才算命中

判定标准(15 条手册细节题) 固定分块 结构分块
理想答案块进入前 10(A) 5/15 10/15
系统第 1 名即理想答案(A=1) 1/15 6/15
A 命中者平均位次 3.0 2.9
理想前 3 块全部进入前 10(B) 2/15 5/15
B 命中者最后一名平均位次 6.5 5.0

早期的概念题(文件级命中判定)两版几乎打平;换成细节题 + 块级判定后,差距直接拉开一倍——因为细节题的答案长在表格行和代码块里,固定分块把它们拦腰切碎后,答案行不是被腰斩就是混进大段噪声;结构分块让表格/代码整体保留,答案才有机会完整命中(A 从 5 条翻到 10 条,B 也翻了一倍多)。评测口径越严,分块的差异越藏不住。直接看内容对比——拿"SORT_BUF_GLOBAL_SIZE 的单位是什么"举例(答案:单位 MB,在第 2 章的 dm.ini 参数表里):

固定分块 Top1(相似度 0.638):
  为了保证归并排序的效率,对于大内存排序缓冲区的总的分片个数系统上限为
  10000 个,因此,当 SORT_BUF_GLOBAL_SIZE 足够大的时候……   ← 叙述段落,无单位

结构分块 Top1(相似度 0.557):
  #### 2.1.1.1 dm.ini
  ##### 2.1.1.1.3 内存相关
  表 2.3 dm.ini 中内存相关参数
  | 参数名 | 缺省值 | 属性 | 说明 |      ← 命中参数表块,带标题路径和表头
  | --- | --- | --- | --- |

固定分块把参数表的行切成了叙述残段(“当 SORT_BUF_GLOBAL_SIZE 足够大时……”),答案行丢了;结构分块把整张参数表保留成带表头、带"表 2.3"身份的表格块,答单位"MB"的那一行就在这块里——块语义完整性的价值,就体现在这类"答案藏在表格里"的问题上。

5.2 检索调优:相似度阈值与命中统计

改了什么:基础版把 Top-10 全部返回,不管相关不相关。进阶版加了一道 0.55 的相似度阈值,并打印"候选 N 块 → 过阈值 M 块 → 返回 K 条"。

为什么:实测中,完全不相关的内容与查询的相似度常常落在 0.3~0.5。没有阈值时,系统会把"矮子里的将军"也塞给你——问一个库里没有答案的问题,它也会硬凑一段相似度 0.4 上下的参数表行回来。0.55 恰好滤掉这层纯噪声。

阈值在数据库侧完成,一条 WHERE 的事(相似度 ≥ 0.55 ⇔ 余弦距离 ≤ 0.45):

WHERE COSINE_DISTANCE(vec, TO_VECTOR(?, 512, FLOAT32)) <= 0.45

实测:拿一个与知识库毫无关系的问题(“今天天气怎么样”)去查,全库 3628 块中相似度最高的一块也只有 0.382——被 0.55 阈值干净地挡在门外,返回 0 条而不是硬凑答案。不过阈值是把双刃剑:评测集里"脏读是什么意思"的正确答案与查询相似度只有 0.548,差 0.002 被阈值拦下——阈值按语料调,这是后话(见 5.4 混合检索如何救回这种边缘案例)。

另一个问题:V$LONG_EXEC_SQLS 的 EXEC_TIME 列单位是什么,第 1 名完整展示,第 2、3 名截断:

第 1 名 | 相似度 0.754 | 附录02-动态性能视图.txt 块276
#### 8. SQL 执行相关
6) **V$LONG_EXEC_SQLS**
当 INI 参数 ENABLE_MONITOR=1 时,显示系统最近 n 条执行时间超过预定值的 SQL 语句。
n 由 INI 参数 LONG_EXEC_SQLS_CNT 指定。默认预定值为 1000 毫秒,可通过 SP_SET_LONG_TIME
系统函数修改,通过 SF_GET_LONG_TIME 系统函数查看当前值。

第 2 名 | 相似度 0.735 | 附录02-动态性能视图.txt 块278
7) **V$SYSTEM_LONG_EXEC_SQLS** 当 INI 参数 ENABLE_MONITOR=1 时,显示系统自启动以
来执行时间最长的 n 条 SQL 语句……默认预定值为 1000 毫秒……

第 3 名 | 相似度 0.706 | sqllog.md 块6
5. EXECTIME_PREC_FLAG设置EXECTIME的时间单位  0:单位为毫秒 MS;1:单位为微秒 US……

从"三个硬凑的不相干块"到"第一块直接命中 V$LONG_EXEC_SQLS 视图说明、第三块给出 EXECTIME 的单位定义(毫秒/微秒)"——语料补全 + 检索调优的组合效果

5.3 索引:精确检索 vs 近似检索(IVFFLAT / HNSW)

改了什么:前面的检索都是"精确检索"——ORDER BY COSINE_DISTANCE(...) FETCH FIRST,达梦全表逐行算距离,一定返回全局最优,代价是数据量大时全表扫描太慢。进阶版用上了达梦的两种向量索引,把"近似检索"这条路铺通:索引建好后,SQL 只多一个 APPROX 关键字:

-- 精确:全表算距离,一定全局最优 ORDER BY COSINE_DISTANCE(vec, TO_VECTOR(?, 512, FLOAT32)) FETCH FIRST 10 ROWS ONLY; -- 近似:走向量索引,快但不保证最优 ORDER BY COSINE_DISTANCE(vec, TO_VECTOR(?, 512, FLOAT32)) FETCH APPROX FIRST 10 ROWS ONLY;

原理:两种索引,两种"偷懒"哲学——

  • IVFFLAT:建索引时把全部向量聚成若干簇(聚类中心),查询时只搜离查询向量最近的几个簇,簇内再精确算。像图书馆查书先找"计算机类"书架,不会把整栋楼翻一遍。
  • HNSW:建一张分层的图——顶层是稀疏的"高速路",越往下越密。查询从顶层开始逐层向下跳,快速逼近最近邻。像导航先上高速、再走城市道路。

image.png

数据库实测(3628 块,同一查询 5 次取平均):

方式 建索引耗时 精确检索 近似检索 Top-10 与精确重合
无索引 12.91ms
IVFFLAT 205ms 15.30ms 11.21ms 10/10
HNSW 1416ms 10.78ms 9.51ms 10/10

从结果可以看出在 3628 条向量的小规模数据集上,IVFFLAT 和 HNSW 均能达到与精确检索一致的 Top-10 检索结果(10/10)。

其中,HNSW 的查询速度略快于 IVFFLAT,但建索引耗时约为 IVFFLAT 的 7 倍。这是因为 IVFFLAT 主要通过聚类将向量划分为多个簇,建索引过程相对简单;而 HNSW 需要逐步构建多层近邻图,并维护节点之间的连接关系,因此索引构建开销更大。

随着数据规模增长至百万级甚至更高时,HNSW 在查询性能上的优势通常会更加明显,而向量索引的价值也将真正体现。达梦已经完整支持了"建索引 → FETCH APPROX"这一近似检索流程,为大规模向量检索提供了基础底座。

5.4 混合检索:全文 + 向量,双引擎取长补短

改了什么:向量检索靠"语义",但抓不住精确的词。达梦还有一套独立的全文索引引擎,进阶版把两路结果用 RRF(倒数排名融合)合并,各取所长。

原理一:达梦的全文索引是什么。它和向量索引是两回事:全文索引在词库的辅助下对文本分词,把每个词映射到出现它的文档(倒排表),查询时按词直接定位——这是数据库传统的"关键词检索"。建索引一条 SQL、填充一条 SQL:

-- 在 content 列上建全文索引,指定中文分词器 CREATE CONTEXT INDEX cti_doc_chunks ON doc_chunks(content) LEXER CHINESE_LEXER; -- 建完必须填充(完全更新),否则查不到任何内容 ALTER CONTEXT INDEX cti_doc_chunks ON doc_chunks REBUILD; -- 关键词检索:CONTAINS 谓词 SELECT file_name, chunk_index FROM doc_chunks WHERE CONTAINS(content, 'sp_close_session');

原理二:为什么混合。两种引擎各有致命短板:全文检索抓词不抓义——问"怎么查看慢SQL",分词器切不出"慢SQL"这种混合词,0 命中;向量检索抓义不抓词——问一个具体的函数名 sp_close_session,它把高分给了"语义相近"的文档,而不是真正调用过这个函数的块。RRF 融合的思路很朴素:两路各取前 60 名,每块得分 = Σ 1/(60+排名),两路都靠前的块自然胜出——向量负责语义、全文负责精确词。

image.png

拿全部 30 条运维细节题来对比纯向量与混合(判定标准同 5.1:人工标注的理想答案块出现在系统前 10 名内):

判定标准(30 条细节题) 纯向量 混合(RRF)
理想答案块进入前 10(A) 19/30 21/30
系统第 1 名即理想答案(A=1) 8/30 6/30
A 命中者平均位次 2.9 3.3
理想前 3 块全部进入前 10(B) 9/30 10/30
B 命中者最后一名平均位次 4.4 6.3

混合的整体命中(A)反超纯向量 2 条——两路检索确实各补所长。最有戏剧性的是一个概念题案例——“脏读是什么意思”:

纯向量:✗ 未命中——正确块(第19章 事务隔离级)与查询相似度只有 0.548,
        被 0.55 阈值拦在门外(见 5.2 那把双刃剑)
混合:✓ 命中——全文检索直接找到含"脏读"字样的块,RRF 把它顶上来了

语义向量觉得"不够像"的查询,全文索引用"词确实存在"把它捞了回来;反过来,纯代码类问题(如 sf_get_session_sql 这种函数名)混合也经常救不回来——RRF 不做相关性判断,它只是忠实地把两路的共识排在前面。语义靠向量、关键词靠全文,各干各擅长的,混合让它们互相兜底——但细节题的"深水区"还远没趟完。

5.5 INT8 量化:存储省一半,精度没掉

改了什么:向量列一直用 FLOAT32 存——每个元素 4 字节。达梦的 VECTOR 类型其实支持三种元素格式:INT8(1 字节)、FLOAT32(4 字节,默认)、FLOAT64(8 字节)。进阶版把 INT8 用了起来。

原理:为什么 INT8 能省空间、又为什么精度损失小。每个元素从 4 字节压到 1 字节,存储直接省 75%——代价是数值只能取整数(-128~127),精度当然有损失,关键是损失了多少。bge 模型输出的向量经过归一化,所有元素都落在 [-1, 1] 之间,量化公式一行:q = round(v × 127),把 [-1,1] 映射到 [-127,127] 的整数。127 级分辨率对"判断谁更相似"来说足够精细——余弦相似度看的是方向,1/127 的量化误差改变不了相对顺序,所以精度几乎无损。

数据库实测(3628 块,FLOAT32 原表 vs INT8 对比表):

对比项 FLOAT32 INT8
整表占用(USER_SEGMENTS) 11.00 MB 5.00 MB(省 55%)
向量列理论占用 7.09 MB 1.77 MB(4.00x)
3 组查询 Top-10 与 FLOAT32 重合 10/10、10/10、9/10

整表省一半以上,检索结果和 FLOAT32 完全一致——语料到千万块时,这个"省"就是实打实的存储成本。

至此,进阶版的每一步升级都有了数据库实测背书:分块让细节题命中翻倍、阈值滤掉硬凑答案、索引为大数据铺路、混合把两路引擎兜在一起、INT8 把存储砍半——全部数字可复现(评测方法与 30 个测试问题见文末附录)。

5.6 检索之外:让知识库开口说话

RAG 全称是"检索增强生成"——前面 5.1~5.5 打磨的都是"检得准",那是达梦数据库的活;但光有检索,知识库只是本"查得到但不会说"的手册。最后一步把检索结果喂给大模型,让它只依据我给的片段把答案组织成人话,第 3 章原理图里那个环才算真正闭上。

这一步代码极薄(一次 HTTP 调用),真正花心思的是防幻觉的两道闸:

  1. 检索结果带编号进上下文——每条片段前缀 [1] 来源 文件名#块号,模型引用了哪块,[n] 一标,读者回车就能回去核对;
  2. 系统提示词把规矩写死——只依据片段回答、没找到就直说"知识库里没找到",不许编。

生成用的系统提示词(完整版,advanced/ask.py):

你是「达梦 DBA 知识助手」,知识来源是达梦官方手册和资深 DBA 的运维笔记。
回答规则:
1. 只依据下方【知识库片段】回答,不要使用片段之外的知识,不要编造;
2. 片段里没有答案时,直接说明「知识库里没找到相关内容」,不要猜测硬答;
3. 引用片段时在句末标 [n](n 为片段编号);
4. SQL 与命令尽量原样给出,中文回答,先给结论再给依据。

一次真实问答(python .\advanced\ask.py --hybrid,混合检索 top-5,DeepSeek 作答):

python .\advanced\ask.py --hybrid
RAG 问答模式:混合检索 + DeepSeek deepseek-chat(top-5 块进上下文)
输入问题回车提问,空行退出。
全文索引已存在,直接复用。
全文索引已填充(0.4 秒)


问题:怎么创建堆表索引?
正在加载模型 BAAI/bge-small-zh-v1.5 ...(首次约 10~30 秒,之后很快)
Warning: You are sending unauthenticated requests to the HF Hub. Please set a HF_TOKEN to enable higher rate limits and faster downloads.
Loading weights: 100%|███████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 71/71 [00:00<00:00, 11822.45it/s]
模型加载完成。
检索到 5 块(11.1 秒),正在让大模型作答…

[回答](问题:怎么创建堆表索引?)
创建堆表索引与普通表类似,但需注意堆表不支持聚集索引[2]。以下是具体方法:

**结论**:堆表支持创建二级索引(非聚集索引),但不支持显式创建聚集索引[2]。

**依据**:
1. 堆表默认会创建一个仅含根页信息的聚集索引,显式建立聚集索引是不允许的[2]。
2. 创建普通索引的语法与普通表一致,例如:
```sql
CREATE INDEX idx_name ON heap_table(column_name);
```
此操作需满足权限条件:表在自身模式中,或具有 CREATE INDEX 权限等[5]。

**注意事项**:
- 若需借助聚集索引键对数据进行排序,不推荐使用堆表[2]。
- 堆表不支持列存储,因此不能创建列存储相关的索引[2]。

[参考来源]
  [1] 第17-管理堆表.txt #5 · 相似度 0.669
  [2] 第17-管理堆表.txt #6 · 相似度 0.662
  [3] 第10-管理索引.txt #35 · 相似度 0.653
  [4] 第10-管理索引.txt #34 · 相似度 0.652
  [5] 第10-管理索引.txt #32 · 相似度 0.650

问题:我该怎么批量关闭所有会话
检索到 5 块(0.1 秒),正在让大模型作答…

[回答](问题:我该怎么批量关闭所有会话)
要批量关闭所有会话,可以使用达梦提供的 `SP_CLOSE_SESSION` 存储过程,结合动态生成 SQL 的方式实现。具体方法如下:

1. **查询当前非空闲会话并生成关闭语句**  
   通过 `V$SESSIONS` 视图筛选出状态不为 `IDLE` 的会话(即正在执行操作的会话),并拼接出对应的关闭命令:
   ```sql
   select 'sp_close_session('||sess_id||');' from v$sessions where state!='IDLE';
   ```
   该语句会输出类似 `sp_close_session(12345);` 的关闭命令列表[1]。

2. **执行生成的关闭语句**  
   将上一步查询出的结果逐条执行,即可批量关闭这些会话。注意,此操作会强制终止相关会话,请谨慎使用,避免影响正在运行的重要事务。

**补充说明**:  
- `V$SESSIONS` 视图中的 `SESS_ID` 是会话 ID,`STATE` 字段表示会话状态,其中 `IDLE` 表示空闲,`ACTIVE` 表示活动(正在执行操作)[2]。  
- 若需查看当前会话总数或等待任务数等信息,可查询 `V$SESSIONS_SYS` 或 `GV$SESSION_SYS` 视图[3][5]。

[参考来源]
  [1] Obsidian-达梦/3.快查/查询慢sql.md #5 · 相似度 0.546
  [2] 附录02-动态性能视图.txt #227 · 相似度 0.540
  [3] 附录02-动态性能视图.txt #242 · 相似度 0.529
  [4] Obsidian-达梦/3.快查/内存相关.md #3 · 相似度 0.526
  [5] 附录02-动态性能视图.txt #244 · 相似度 0.510

问题:怎么清理特定sql的执行计划缓存?
检索到 5 块(0.1 秒),正在让大模型作答…

[回答](问题:怎么清理特定sql的执行计划缓存?)
要清理特定 SQL 的执行计划缓存,可以分两步操作:先查询出该 SQL 对应的缓存编号,再调用清理函数删除指定缓存[2]。

具体步骤如下:

1. **查询特定 SQL 的缓存编号**  
   使用 `v$cachepln` 动态视图,通过 `sqlstr` 字段匹配目标 SQL 语句,获取其 `cache_item`(缓存编号)[2]:
   ```sql
   SELECT cache_item, sqlstr
   FROM v$cachepln
   WHERE upper(sqlstr) LIKE '%SQL语句%';
   ```

2. **清理指定缓存**  
   将上一步查询到的 `cache_item` 作为参数,调用 `sp_clear_plan_cache()` 过程进行清理[2]:
   ```sql
   CALL sp_clear_plan_cache(缓存编号);
   ```

**补充说明**:  
- 如果希望某条 SQL 的执行计划完全不进入缓存,可在语句中使用 HINT `/*+PLAN_NO_CACHE*/`,这样该语句的执行计划生成后将不会被缓存[4]。  
- 若需清理所有执行计划缓存,可直接执行 `SP_CLEAR_PLAN_CACHE();`[2]。

[参考来源]
  [1] 第02-DM-物理存储结构.txt #110 · 相似度 0.614
  [2] Obsidian-达梦/3.快查/清理执行计划缓存.md #0 · 相似度 0.822
  [3] 第01-DM-逻辑结构概述.txt #6 · 相似度 0.589
  [4] 第23-SQL-调优.txt #60 · 相似度 0.707
  [5] 第01-DM-逻辑结构概述.txt #20 · 相似度 0.586

问题:V$LONG_EXEC_SQLS视图EXEC_TIME列的单位是什么
检索到 5 块(0.1 秒),正在让大模型作答…

[回答](问题:V$LONG_EXEC_SQLS视图EXEC_TIME列的单位是什么)
根据知识库片段,V$LONG_EXEC_SQLS 视图的 EXEC_TIME 列单位没有在片段中明确说明,但片段提到该视图显示“执行时间超过预定值”的 SQL,且预定值默认单位为毫秒(通过 SP_SET_LONG_TIME 修改,默认 1000 毫秒)[1]。因此,EXEC_TIME 列通常以毫秒为单位,但知识库未直接给出该列的单位定义,建议结合达梦官方文档确认。

[参考来源]
  [1] 附录02-动态性能视图.txt #276 · 相似度 0.726
  [2] 附录02-动态性能视图.txt #278 · 相似度 0.702
  [3] Obsidian-达梦/3.快查/查询慢sql.md #0 · 相似度 0.625
  [4] Obsidian-达梦/3.快查/sqllog.md #6 · 相似度 0.703
  [5] 附录02-动态性能视图.txt #379 · 相似度 0.698

模型没有硬下结论,而是老老实实答"知识库未直接给出该列的单位定义,建议结合达梦官方文档确认"——幻觉不是靠模型自觉治好的,是靠提示词和引用机制按住的。检索端做得越准,这一步的"地基"越稳;生成端守得住边界,知识库才敢叫"助手"而不是"嘴替"。

6. 总结

用达梦9 搭一个个人 RAG 知识库,全程下来我的感受是:达梦把向量能力做成了一件"DBA 顺手就能用"的事——建表、插入、距离计算、精确/近似检索、全文索引、INT8 量化,全部标准 SQL,全部开箱即用;再接一条 OpenAI 兼容的 API 让大模型对着检索结果开口回答(5.6),个人知识库就从"查得到"进化成了"答得出"。3628 块语料、30 条运维细节题(参数取值、SQL 语法、函数用法)实测:

  • 语义检索的"深水区"比想象中深:理想答案块进入系统前 10 的比例只有六到七成(纯向量 19/30、混合 21/30)——概念题检索还行,细节题(表格行里的参数值、代码块里的函数名)明显吃力;
  • 分块质量在细节题上决定生死:15 条手册细节题,固定分块只命中 5 条,结构分块命中 10 条——表格与代码被切碎与否,直接决定"参数单位"这类答案找不找得到;
  • 混合检索让两路互相兜底:全文路把被阈值拦下的"脏读"捞了回来,整体命中从 19 提到 21;
  • IVFFLAT/HNSW 索引随手可建(近似检索 9~11ms)、INT8 量化存储减半精度不损。

达梦9 完全可以作为 RAG 应用的向量数据库选择。

还可以改进的地方

这一路评测暴露的短板,就是下一步的路线图:

  1. 代码块与表格行的专项检索sf_get_session_sqlEXEC_TIME 单位毫秒这类"精确行"答案经常沉在前 10 之外——可以对代码块/表格行做结构标记,检索时定位到"块内的哪一行",或给代码块加权;
  2. Rerank 精排:向量召回 Top-50 后用交叉编码器(如 bge-reranker)精排,最可能救回"答案在但排名不够靠前"的案例;
  3. 查询改写:“版本怎么看"这类问题与笔记里的纯 SQL 代码(select id_code)语义距离远,查询改写(补全成"查看数据库版本的 SQL”)能显著改善;
  4. 阈值自适应:0.55 是拍脑袋定死的,0.548 的正确答案被它拦下——应该按查询质量动态调整或给出"低置信度"提示;
  5. 评测集持续扩充:30 条细节题只是起点,标注库会随使用增长,让每一次改进都有数字可对比。

给达梦的几个建议

  1. dmPython 直接提供向量绑定类型:现在插入向量要把 numpy 数组拼成 [0.1,0.2,...] 字符串再由 TO_VECTOR 转——裸驱动如果有原生的 list/ndarray 绑定,能少一层心智负担(dmSQLAlchemy 已经有了,希望下沉到 dmPython);
  2. 提供官方 RAG/向量生态对接:比如与 LlamaIndex / LangChain / sentence-transformers 的官方集成库或示例仓库——国产数据库的向量能力需要让 AI 生态"开箱即用";
  3. 向量检索的评测与调优指引:聚类中心数、TARGET ACCURACY、HNSW 参数怎么选,手册只有参数说明没有调优方法论——一篇"向量索引调优实践"能帮 DBA 少走很多弯路。

代码与数据已开源

项目已完整开源(MIT 协议):

https://github.com/a-lost-horse/dmrag

  • simple/:单文件极简版——第 4 章的全部代码,一个文件 5 个步骤;
  • advanced/:进阶版完整实现(结构分块 / 阈值 / 混合检索 / 向量索引 / INT8 量化 / 大模型问答);
  • demo_scripts/:三个达梦能力对比演示 + 两个评测脚本,第 5 章所有数字的复跑入口;
  • demo_corpus/eval_data/:演示语料(达梦手册按章拆分)和 30 条评测集的人工标注答案。

欢迎交流指正。

附:评测方法——评分标准与 30 个测试问题

第 5 章的所有对比都建立在下面的评测方法上。

对每个问题,人工先给出「理想第 1 名答案块」和「理想前 3 名答案块」(精确到 file_name + chunk_index,
判定时不看系统结果、只看内容与问题是否直接对应),然后让系统检索,看理想块落在哪里:

A = 理想第 1 名块在系统前 10 名中的位次(越低越好;不在前 10 = 失败)
B = 理想前 3 名三块中最差的一个在前 10 中的位次(三块都要出现;缺任意一块 = 失败)

30 个测试问题全部是运维细节题——答案是一个具体的命令、SQL、函数或参数值,能在一个块内找到,覆盖官方手册(第 2/7/10/14/16/17/18/19/22/25 章与附录 2)和我的运维笔记(查询慢SQL、备份还原、版本查询、统计信息、sqllog、主备部署等)。注:理想答案块号基于 2026-09 语料入库结果标注,重新入库后块号会漂移,需按"答案内容"重新定位(问题清单不受影响):

# 测试问题 理想答案块
1 查询达梦历史慢SQL用哪条SQL语句 查询慢sql.md 块0
2 Linux终端手动启动DM实例用什么命令 第07-启动和关闭数据库.txt 块10
3 达梦数据库关闭方式有哪几种 第07-启动和关闭数据库.txt 块15
4 创建表空间并指定数据文件的SQL示例 第14-数据库布局和存储管理.txt 块1
5 SORT_BUF_GLOBAL_SIZE参数的取值范围 第02-DM-物理存储结构.txt 块18
6 SORT_BUF_SIZE参数的默认值 第02-DM-物理存储结构.txt 块17
7 MAX_OS_MEMORY参数的默认值 第02-DM-物理存储结构.txt 块9
8 HUGE_BUFFER参数的取值范围 第02-DM-物理存储结构.txt 块12
9 V$LONG_EXEC_SQLS视图EXEC_TIME列的数据类型和单位 附录02-动态性能视图.txt 块277
10 创建达梦全文索引的SQL语句 第18-全文检索.txt 块7
11 重新填充全文索引的SQL语句 第18-全文检索.txt 块12
12 达梦开启SVR_LOG参数用哪个函数 sqllog.md 块0
13 刷新sqllog.ini配置的存储过程 sqllog.md 块0
14 清空全部执行计划缓存的SQL 清理执行计划缓存.md 块0
15 获取会话正在执行的SQL文本用哪个函数 查询慢sql.md 块3
16 终止指定会话的SQL写法 查询慢sql.md 块1
17 按段大小找出库中大表的SQL 大表查询.md 块0
18 达梦查看数据库版本的SQL怎么写 版本查询.md 块1
19 收集表统计信息调用哪个DBMS_STATS过程 统计信息.md 块0
20 disql下执行热备的命令 备份还原.md 块1
21 达梦主备搭建时备库的初始操作是什么 标准部署-主备Linux.md 块23
22 达梦查看执行计划的语句怎么写 sql优化基本步骤.md 块0
23 DENSE_RANK排名取每组前三的写法 185.DENSE_RANK-部门工资前三高的所有员工.md 块1
24 达梦给SQL注入hint的函数 hint相关.md 块0
25 查询缓冲池占用大小的SQL 内存相关.md 块0
26 达梦普通表的ROWID是逻辑还是物理 第17-管理堆表.txt 块0
27 设置事务为读提交隔离级的语句 第19-管理事务.txt 块45
28 达梦创建HUGE表的语句怎么写 第16-管理列存储表.txt 块19
29 FIRST_ROWS参数设为10的含义 第22-查询优化.txt 块0
30 低于哪个版本升级需要升级两次 第25-版本升级.txt 块3

#达梦数据库 #达梦同行者征文

评论
后发表回复

作者

文章

阅读量

获赞

扫一扫
联系客服