HarmonyOS 新手入门:ArkData 向量数据库,它不是 RDB 的替代品
HarmonyOS 新手入门:ArkData 向量数据库,它不是 RDB 的替代品
上一篇跑通了最小闭环:保存向量字段,再用 SQL 距离排序找回来。
这一篇接着讲一个更实际的问题:用了向量数据库,不代表所有查询都要变成“相似度”。分类、状态、时间、租户、权限这些确定条件,仍然应该交给 RDB。
先说结论
向量检索负责“谁更像”,RDB 条件负责“哪些记录有资格参与比较”。
真实业务里,通常不是全库做相似度排序,而是先用分类、状态、权限、时间范围缩小候选集,再在候选记录里做向量召回。
这个 Demo 做什么
页面保存案例时同时写入:
category_name:普通 RDB 字段,用来做精确过滤。repr floatvector(2):向量字段,用来做距离排序。
查询时分两种:
-- 不限分类
ORDER BY repr <=> ? LIMIT ?
-- 指定分类
WHERE category_name = ? ORDER BY repr <=> ? LIMIT ?
这就是“先缩小候选集,再做相似召回”的入门模型。
它怎么用
官方文档说明:floatvector 保存向量数据,<=> 是余弦距离,<-> 是 L2 距离;向量索引命中要求查询是 ORDER BY + LIMIT,并且查询距离度量和创建索引时的度量一致。
所以这个 Demo 创建了:
CREATE INDEX IF NOT EXISTS hybrid_vector_idx
ON hybrid_cases_v2 USING GSDISKANN(repr COSINE)
查询也使用 repr <=> ?,保持 COSINE 语义一致。
容易踩坑的地方
用户权限、业务状态、时间范围、分类这些条件,应该优先变成普通 SQL 条件。向量检索负责排序“候选记录里哪条最像”。这样结果更稳定,也更容易解释和调试。
这个 Demo 依赖 API 18+ 的向量数据库能力。除了设备要支持,项目的 compatibleSdkVersion 也要使用 API 18 或更高版本;否则建 floatvector 表时可能直接返回 801 Capability not support。
另外,打开数据库时不能是只读模式。这里会显式设置 isReadOnly: false,避免只读配置让 CREATE TABLE、插入和更新直接失败。
欢迎评论区一起分享~
你们做相似检索时,哪些条件必须先用 RDB 过滤?分类、权限、租户、状态,还是时间范围?如果遇到过“全库向量检索结果不稳定”的问题,也可以把场景发出来。
完整示例代码
文件位置:ArkDataVectorRdbHybridDemo.ets
这里也要分清楚:RDB + 向量检索解决的是“怎么存、怎么过滤、怎么按距离排序”,不负责把文本自动变成模型向量。官方“应用数据向量化 / Embedding”能力属于另一层 API,它的 2in1 等设备形态约束需要单独判断。
因此这个 Demo 继续使用二维教学向量,不调用 @ohos.data.intelligence。如果真实业务要接模型向量,先确认 Embedding API 在目标设备上可用,再把 floatvector(2) 改成模型输出维度。
import { common } from '@kit.AbilityKit';
import { relationalStore } from '@kit.ArkData';
import { promptAction, router } from '@kit.ArkUI';
import { BusinessError } from '@kit.BasicServicesKit';
const DB_NAME: string = 'vector_rdb_hybrid_demo.db';
const TABLE_CASE: string = 'hybrid_cases_v2';
const VECTOR_INDEX: string = 'hybrid_vector_idx';
const VECTOR_DIMENSION: number = 2;
const RESULT_LIMIT: number = 8;
const ERROR_CAPABILITY_NOT_SUPPORTED: number = 801;
const VECTOR_SOURCE_TEXT: string = '二维教学向量,不调用官方 Embedding';
const EMBEDDING_SCOPE_TEXT: string = 'Embedding 设备形态限制需单独判断';
const VECTOR_TABLE_UNAVAILABLE_TIP: string =
'isVectorSupported 返回支持,但 CREATE TABLE floatvector 返回 801。当前 Demo 已显式设置 isReadOnly=false;Demo 使用二维教学向量,不调用官方 Embedding。若仍失败,请继续检查系统镜像或真机向量数据库能力。';
class HybridCaseItem {
id: number = 0;
title: string = '';
categoryName: string = '';
content: string = '';
distance: number = 0;
vectorText: string = '';
createdAt: string = '';
constructor(
id: number,
title: string,
categoryName: string,
content: string,
distance: number,
vectorText: string,
createdAt: string
) {
this.id = id;
this.title = title;
this.categoryName = categoryName;
this.content = content;
this.distance = distance;
this.vectorText = vectorText;
this.createdAt = createdAt;
}
}
class HybridInfoItem {
label: string = '';
value: string = '';
constructor(label: string, value: string) {
this.label = label;
this.value = value;
}
}
@Entry
@Component
struct ArkDataVectorRdbHybridDemo {
@State caseTitle: string = '首选项保存主题';
@State caseContent: string = '用户主题、字号、引导页状态这类轻量配置,优先用 Preferences 保存。';
@State caseCategory: string = 'ArkData';
@State searchCategory: string = '全部';
@State queryText: string = '主题模式应该怎么保存';
@State tips: string = '先用 RDB 分类过滤,再让向量数据库对候选记录做距离排序。';
@State items: HybridCaseItem[] = [];
@State infoItems: HybridInfoItem[] = [];
@State showResultSheet: boolean = false;
@State vectorSupportedText: string = '待检测';
@State stageText: string = '等待初始化';
@State indexStatusText: string = '尚未创建';
private store: relationalStore.RdbStore | undefined = undefined;
private categories: string[] = ['ArkData', 'ArkUI', 'AI'];
private searchCategories: string[] = ['全部', 'ArkData', 'ArkUI', 'AI'];
aboutToAppear(): void {
this.vectorSupportedText = relationalStore.isVectorSupported() ? '支持' : '不支持';
if (relationalStore.isVectorSupported()) {
this.loadCases();
} else {
this.items = [];
this.tips = '当前设备不支持向量数据库,页面可查看组合检索流程。';
this.updateInfoItems('设备不支持向量能力', 0);
}
}
private errorToText(err: BusinessError): string {
return `code=${err.code}, message=${err.message}`;
}
private isCapabilityNotSupported(err: BusinessError): boolean {
return err.code === ERROR_CAPABILITY_NOT_SUPPORTED;
}
private markVectorTableUnavailable(action: string): void {
this.items = [];
this.vectorSupportedText = '建表不支持(801)';
this.indexStatusText = '未创建';
this.tips = VECTOR_TABLE_UNAVAILABLE_TIP;
this.updateInfoItems(action, 0);
}
private async getStore(): Promise<relationalStore.RdbStore> {
if (this.store !== undefined) {
return this.store;
}
if (!relationalStore.isVectorSupported()) {
throw new Error('Vector store is not supported on this device');
}
const context = getContext(this) as common.UIAbilityContext;
const config: relationalStore.StoreConfig = {
name: DB_NAME,
securityLevel: relationalStore.SecurityLevel.S1,
vector: true,
isReadOnly: false
};
this.stageText = '正在打开向量 RDB';
const store = await relationalStore.getRdbStore(context, config);
this.stageText = '正在创建组合检索表';
await store.executeSql(
`CREATE TABLE IF NOT EXISTS ${TABLE_CASE} (` +
'id INTEGER PRIMARY KEY AUTOINCREMENT, ' +
'title TEXT NOT NULL, ' +
'category_name TEXT NOT NULL, ' +
'content TEXT NOT NULL, ' +
`repr floatvector(${VECTOR_DIMENSION}) NOT NULL, ` +
'created_at TEXT NOT NULL)'
);
await this.prepareVectorIndex(store);
this.store = store;
return store;
}
private async prepareVectorIndex(store: relationalStore.RdbStore): Promise<void> {
try {
this.stageText = '正在创建向量索引';
await store.executeSql(
`CREATE INDEX IF NOT EXISTS ${VECTOR_INDEX} ON ${TABLE_CASE} USING GSDISKANN(repr COSINE)`
);
this.indexStatusText = '已创建';
} catch (err) {
const error = err as BusinessError;
this.indexStatusText = '创建失败,已降级为无索引向量查询';
console.error(`Hybrid vector index create failed: stage=${this.stageText}, ${this.errorToText(error)}`);
}
}
private createTeachingVector(text: string): Float32Array {
const source = text.length > 0 ? text : '空文本';
let dataScore = 0;
let uiScore = 0;
for (let index = 0; index < source.length; index++) {
const char = source.charAt(index);
const code = source.charCodeAt(index);
if (char === 'D' || char === 'd' || char === '数' || char === '存' || char === '库') {
dataScore += 2;
} else if (char === 'U' || char === 'u' || char === '界' || char === '面' || char === '视') {
uiScore += 2;
} else if (code % 2 === 0) {
dataScore += 1;
} else {
uiScore += 1;
}
}
const total = Math.max(1, dataScore + uiScore);
return Float32Array.from([dataScore / total, uiScore / total]);
}
private vectorToText(vector: Float32Array): string {
if (vector.length < VECTOR_DIMENSION) {
return '[]';
}
return `[${vector[0].toFixed(3)}, ${vector[1].toFixed(3)}]`;
}
private readVector(value: relationalStore.ValueType): Float32Array {
if (value instanceof Float32Array) {
return value;
}
return new Float32Array(0);
}
private updateInfoItems(action: string, filteredCount: number): void {
this.infoItems = [
new HybridInfoItem('数据库名称', DB_NAME),
new HybridInfoItem('表名', TABLE_CASE),
new HybridInfoItem('普通字段', 'title / category_name / content / created_at'),
new HybridInfoItem('向量字段', `repr floatvector(${VECTOR_DIMENSION})`),
new HybridInfoItem('向量索引', `${VECTOR_INDEX} / GSDISKANN / COSINE / ${this.indexStatusText}`),
new HybridInfoItem('向量来源', VECTOR_SOURCE_TEXT),
new HybridInfoItem('Embedding', EMBEDDING_SCOPE_TEXT),
new HybridInfoItem('只读模式', 'false'),
new HybridInfoItem('向量能力', this.vectorSupportedText),
new HybridInfoItem('初始化阶段', this.stageText),
new HybridInfoItem('RDB 分类条件', this.searchCategory),
new HybridInfoItem('当前结果数', `${filteredCount}`),
new HybridInfoItem('最近动作', action)
];
}
private readRows(resultSet: relationalStore.ResultSet, hasDistance: boolean): HybridCaseItem[] {
const rows: HybridCaseItem[] = [];
const idIndex = resultSet.getColumnIndex('id');
const titleIndex = resultSet.getColumnIndex('title');
const categoryIndex = resultSet.getColumnIndex('category_name');
const contentIndex = resultSet.getColumnIndex('content');
const vectorIndex = resultSet.getColumnIndex('repr');
const createdAtIndex = resultSet.getColumnIndex('created_at');
const distanceIndex = hasDistance ? resultSet.getColumnIndex('distance') : -1;
while (resultSet.goToNextRow()) {
const vector = this.readVector(resultSet.getValue(vectorIndex));
const distance = hasDistance ? resultSet.getDouble(distanceIndex) : 0;
rows.push(new HybridCaseItem(
resultSet.getLong(idIndex),
resultSet.getString(titleIndex),
resultSet.getString(categoryIndex),
resultSet.getString(contentIndex),
distance,
this.vectorToText(vector),
resultSet.getString(createdAtIndex)
));
}
return rows;
}
private async loadCases(action: string = '读取全部案例'): Promise<void> {
let resultSet: relationalStore.ResultSet | undefined = undefined;
if (!relationalStore.isVectorSupported()) {
this.items = [];
this.vectorSupportedText = '不支持';
this.tips = '当前设备不支持向量数据库,无法创建 floatvector 表。';
this.updateInfoItems('设备不支持向量能力', 0);
return;
}
try {
const store = await this.getStore();
this.stageText = '正在读取组合检索表';
resultSet = await store.querySql(
`SELECT id, title, category_name, content, repr, created_at FROM ${TABLE_CASE} ORDER BY id DESC`
);
this.items = this.readRows(resultSet, false);
this.stageText = '读取完成';
this.updateInfoItems(action, this.items.length);
} catch (err) {
const error = err as BusinessError;
if (this.isCapabilityNotSupported(error)) {
this.markVectorTableUnavailable('向量表不可用');
promptAction.showToast({ message: '向量表不可用' });
console.error(`Hybrid vector load failed: stage=${this.stageText}, ${this.errorToText(error)}`);
return;
}
this.tips = `读取失败:阶段=${this.stageText},${this.errorToText(error)}。`;
this.items = [];
this.updateInfoItems('读取失败', 0);
promptAction.showToast({ message: '读取失败' });
console.error(`Hybrid vector load failed: stage=${this.stageText}, ${this.errorToText(error)}`);
} finally {
if (resultSet !== undefined) {
resultSet.close();
}
}
}
private async saveCase(): Promise<void> {
try {
if (!relationalStore.isVectorSupported()) {
this.tips = '当前设备不支持向量数据库,无法保存向量字段。';
this.updateInfoItems('保存被跳过', this.items.length);
promptAction.showToast({ message: '设备不支持向量能力' });
return;
}
const store = await this.getStore();
const title = this.caseTitle.length > 0 ? this.caseTitle : '未命名案例';
const content = this.caseContent.length > 0 ? this.caseContent : '空内容';
const vector = this.createTeachingVector(`${this.caseCategory}\n${title}\n${content}`);
const values: relationalStore.ValuesBucket = {
title: title,
category_name: this.caseCategory,
content: content,
repr: vector,
created_at: new Date().toString()
};
await store.insert(TABLE_CASE, values);
await this.loadCases('保存案例');
this.tips = `保存成功:${this.caseCategory} 案例已写入向量字段 ${this.vectorToText(vector)}。`;
promptAction.showToast({ message: '案例已保存' });
} catch (err) {
const error = err as BusinessError;
if (this.isCapabilityNotSupported(error)) {
this.markVectorTableUnavailable('保存被跳过');
promptAction.showToast({ message: '向量表不可用' });
console.error(`Hybrid vector save failed: stage=${this.stageText}, ${this.errorToText(error)}`);
return;
}
this.tips = `保存失败:阶段=${this.stageText},${this.errorToText(error)}。`;
promptAction.showToast({ message: '保存失败' });
console.error(`Hybrid vector save failed: stage=${this.stageText}, ${this.errorToText(error)}`);
}
}
private async searchCases(): Promise<void> {
let resultSet: relationalStore.ResultSet | undefined = undefined;
if (!relationalStore.isVectorSupported()) {
this.tips = '当前设备不支持向量数据库,无法执行向量距离检索。';
this.updateInfoItems('检索被跳过', this.items.length);
promptAction.showToast({ message: '设备不支持向量能力' });
return;
}
try {
const store = await this.getStore();
const queryVector = this.createTeachingVector(this.queryText);
this.stageText = '正在执行组合向量检索';
if (this.searchCategory === '全部') {
resultSet = await store.querySql(
`SELECT id, title, category_name, content, repr, created_at, repr <=> ? AS distance FROM ${TABLE_CASE} ` +
'ORDER BY repr <=> ? LIMIT ?',
[queryVector, queryVector, RESULT_LIMIT]
);
} else {
resultSet = await store.querySql(
`SELECT id, title, category_name, content, repr, created_at, repr <=> ? AS distance FROM ${TABLE_CASE} ` +
'WHERE category_name = ? ORDER BY repr <=> ? LIMIT ?',
[queryVector, this.searchCategory, queryVector, RESULT_LIMIT]
);
}
this.items = this.readRows(resultSet, true);
this.stageText = '检索完成';
this.updateInfoItems('RDB 过滤后向量检索', this.items.length);
this.tips = `检索完成:分类=${this.searchCategory},查询向量 ${this.vectorToText(queryVector)}。`;
this.showResultSheet = true;
} catch (err) {
const error = err as BusinessError;
if (this.isCapabilityNotSupported(error)) {
this.markVectorTableUnavailable('检索被跳过');
promptAction.showToast({ message: '向量表不可用' });
console.error(`Hybrid vector search failed: stage=${this.stageText}, ${this.errorToText(error)}`);
return;
}
this.tips = `检索失败:阶段=${this.stageText},${this.errorToText(error)}。`;
promptAction.showToast({ message: '检索失败' });
console.error(`Hybrid vector search failed: stage=${this.stageText}, ${this.errorToText(error)}`);
} finally {
if (resultSet !== undefined) {
resultSet.close();
}
}
}
private async clearCases(): Promise<void> {
try {
if (!relationalStore.isVectorSupported()) {
this.items = [];
this.tips = '当前设备不支持向量数据库,没有可清空的向量表。';
this.updateInfoItems('清空被跳过', 0);
return;
}
const store = await this.getStore();
await store.executeSql(`DELETE FROM ${TABLE_CASE}`);
await this.loadCases('清空表数据');
this.tips = '数据已清空。';
promptAction.showToast({ message: '已清空' });
} catch (err) {
const error = err as BusinessError;
if (this.isCapabilityNotSupported(error)) {
this.markVectorTableUnavailable('清空被跳过');
promptAction.showToast({ message: '向量表不可用' });
console.error(`Hybrid vector clear failed: stage=${this.stageText}, ${this.errorToText(error)}`);
return;
}
this.tips = `清空失败:阶段=${this.stageText},${this.errorToText(error)}。`;
promptAction.showToast({ message: '清空失败' });
console.error(`Hybrid vector clear failed: stage=${this.stageText}, ${this.errorToText(error)}`);
}
}
private async showHybridInfo(): Promise<void> {
await this.loadCases('查看组合检索信息');
this.showResultSheet = true;
}
@Builder
private categoryButtons() {
Row({ space: 8 }) {
ForEach(this.categories, (categoryName: string) => {
Button(categoryName)
.layoutWeight(1)
.height(38)
.fontSize(13)
.fontColor(this.caseCategory === categoryName ? '#FFFFFF' : '#0F172A')
.backgroundColor(this.caseCategory === categoryName ? '#0F766E' : '#CCFBF1')
.onClick(() => {
this.caseCategory = categoryName;
})
}, (categoryName: string) => categoryName)
}
.width('100%')
}
@Builder
private searchCategoryButtons() {
Row({ space: 8 }) {
ForEach(this.searchCategories, (categoryName: string) => {
Button(categoryName)
.layoutWeight(1)
.height(38)
.fontSize(12)
.fontColor(this.searchCategory === categoryName ? '#FFFFFF' : '#0F172A')
.backgroundColor(this.searchCategory === categoryName ? '#155E75' : '#CFFAFE')
.onClick(() => {
this.searchCategory = categoryName;
})
}, (categoryName: string) => categoryName)
}
.width('100%')
}
@Builder
private hybridInfoSheet() {
Column({ space: 14 }) {
Text('RDB + 向量检索信息')
.width('100%')
.fontSize(22)
.fontWeight(700)
.fontColor('#0F172A')
Text('分类交给 RDB,语义距离交给向量数据库;查询保持 ORDER BY + LIMIT。')
.width('100%')
.fontSize(13)
.fontColor('#64748B')
Column({ space: 8 }) {
ForEach(this.infoItems, (item: HybridInfoItem) => {
Column({ space: 4 }) {
Text(item.label)
.fontSize(12)
.fontColor('#64748B')
Text(item.value)
.fontSize(16)
.fontColor('#0F172A')
}
.width('100%')
.padding(12)
.backgroundColor('#F8FAFC')
.borderRadius(8)
}, (item: HybridInfoItem) => item.label)
}
.width('100%')
Button('关闭')
.width('100%')
.height(44)
.fontSize(16)
.backgroundColor('#0F766E')
.onClick(() => {
this.showResultSheet = false;
})
}
.width('100%')
.padding({ left: 20, right: 20, top: 12, bottom: 20 })
}
build() {
Scroll() {
Column({ space: 18 }) {
Row() {
Text('返回')
.fontSize(14)
.fontColor('#0F766E')
.onClick(() => {
router.back();
})
Blank()
}
.width('100%')
Text('RDB + 向量检索')
.width('100%')
.fontSize(28)
.fontWeight(700)
.fontColor('#182431')
Text('分类、状态、时间这类精确条件交给 RDB;意思像不像交给向量距离。')
.width('100%')
.fontSize(14)
.fontColor('#56616F')
Text(`当前向量能力:${this.vectorSupportedText}`)
.width('100%')
.fontSize(13)
.fontColor(this.vectorSupportedText === '支持' ? '#0F766E' : '#B45309')
this.categoryButtons()
TextInput({ placeholder: '案例标题', text: this.caseTitle })
.height(44)
.fontSize(16)
.backgroundColor('#F0FDFA')
.fontColor('#0F172A')
.onChange((value: string) => {
this.caseTitle = value;
})
TextArea({ placeholder: '案例内容', text: this.caseContent })
.height(92)
.fontSize(15)
.backgroundColor('#F0FDFA')
.fontColor('#0F172A')
.onChange((value: string) => {
this.caseContent = value;
})
Text('检索分类')
.width('100%')
.fontSize(13)
.fontColor('#64748B')
this.searchCategoryButtons()
TextInput({ placeholder: '输入检索问题', text: this.queryText })
.height(44)
.fontSize(16)
.backgroundColor('#ECFEFF')
.fontColor('#0F172A')
.onChange((value: string) => {
this.queryText = value;
})
Row({ space: 10 }) {
Button('保存')
.layoutWeight(1)
.height(44)
.fontSize(15)
.backgroundColor('#0F766E')
.onClick(() => {
this.saveCase();
})
Button('过滤检索')
.layoutWeight(1)
.height(44)
.fontSize(15)
.fontColor('#0F172A')
.backgroundColor('#99F6E4')
.onClick(() => {
this.searchCases();
})
Button('查看')
.layoutWeight(1)
.height(44)
.fontSize(15)
.fontColor('#0F172A')
.backgroundColor('#CFFAFE')
.onClick(() => {
this.showHybridInfo();
})
Button('清空')
.layoutWeight(1)
.height(44)
.fontSize(15)
.fontColor('#0F172A')
.backgroundColor('#CBD5E1')
.onClick(() => {
this.clearCases();
})
}
.width('100%')
Column({ space: 8 }) {
ForEach(this.items, (item: HybridCaseItem) => {
Column({ space: 5 }) {
Row() {
Text(item.title)
.fontSize(17)
.fontWeight(700)
.fontColor('#0F172A')
.layoutWeight(1)
Text(item.categoryName)
.fontSize(12)
.fontColor('#0F766E')
}
.width('100%')
Text(item.content)
.width('100%')
.fontSize(13)
.fontColor('#334155')
Text(`distance=${item.distance.toFixed(4)} | vector=${item.vectorText} | id=${item.id}`)
.width('100%')
.fontSize(12)
.fontColor('#64748B')
Text(item.createdAt)
.width('100%')
.fontSize(12)
.fontColor('#94A3B8')
}
.width('100%')
.padding(14)
.backgroundColor('#F8FAFC')
.borderRadius(8)
}, (item: HybridCaseItem) => `${item.id}`)
}
.width('100%')
Text(this.tips)
.width('100%')
.fontSize(13)
.fontColor('#64748B')
}
.width('100%')
.padding(24)
}
.width('100%')
.height('100%')
.backgroundColor('#FFFFFF')
.bindSheet(this.showResultSheet, this.hybridInfoSheet(), {
height: SheetSize.MEDIUM,
dragBar: true,
showClose: true,
onDisappear: () => {
this.showResultSheet = false;
}
})
}
}
- 点赞
- 收藏
- 关注作者
评论(0)