HarmonyOS 新手入门:ArkData 向量数据库,做一个本地知识库
HarmonyOS 新手入门:ArkData 向量数据库,做一个本地知识库
前两篇分别讲了最小闭环和 RDB 混合过滤。第三篇把它落到一个常见 AI 应用场景:本地知识库。
先说结论
本地知识库不是把整篇文章直接塞进一个向量字段。
更常见的做法是:先把长文本切成片段,再给每个片段生成向量。检索时先找相似片段,后续再把片段交给摘要、问答或生成模块。
这个 Demo 做什么
页面把一段长文本切成多个 chunk,每个 chunk 单独写入 RDB 表:
source_title保存资料标题。chunk_text保存片段正文。repr floatvector(2)保存片段向量。created_at保存写入时间。
检索时把问题也转成二维教学向量,然后用官方文档里的余弦距离写法召回:
SELECT id, source_title, chunk_text, repr, created_at, repr <=> ? AS distance
FROM kb_chunks_v2
ORDER BY repr <=> ?
LIMIT ?
为什么要切片
知识库通常不是整篇文章直接入库。更常见的做法是先切成较短片段,再分别向量化。这样检索结果可以直接定位到具体片段,后续也更容易把片段交给摘要、问答或生成模块。
它怎么用
这篇继续遵循官方向量数据库文档的核心约束:打开数据库时启用 vector: true,表字段使用 floatvector(N),索引使用 GSDISKANN,查询使用 ORDER BY + LIMIT。
Demo 使用二维教学向量是为了让新手能看到完整数据流。真实项目替换为模型向量时,需要把 floatvector(2) 改成模型输出维度,并保证写入的 Float32Array 长度一致。
如果页面显示 建表不支持(801),优先检查两件事:运行环境是否真支持向量数据库,以及项目 compatibleSdkVersion 是否已经升到 API 18+。只看 isVectorSupported() 不够,最终还要以 CREATE TABLE ... floatvector 是否成功为准。
还要确认数据库不是只读打开。isReadOnly: true 会让写 SQL 直接失败;这个 Demo 已经显式写成 isReadOnly: false。
欢迎评论区一起分享~
你们做知识库时会怎么切片?按字数、按段落、按标题层级,还是按业务字段?如果后续要接真实模型向量,这个切片策略会直接影响召回效果。
完整示例代码
文件位置:ArkDataVectorKnowledgeBaseDemo.ets
知识库 Demo 也不要误会成“手机端一定可以直接调用官方文本转向量”。这里的向量来自页面里的二维教学算法,只是为了把切片、入库、召回这条链路跑通。
官方“应用数据向量化 / Embedding”是单独的模型能力,文档里的 2in1 等设备形态约束需要单独看。真实项目要做本地知识库,先确认目标设备能不能生成模型向量,再决定是端侧 Embedding、云侧生成,还是用其它模型服务生成后写入 floatvector(N)。
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_knowledge_base_demo.db';
const TABLE_CHUNK: string = 'kb_chunks_v2';
const VECTOR_INDEX: string = 'kb_chunk_vector_idx';
const VECTOR_DIMENSION: number = 2;
const RESULT_LIMIT: number = 8;
const CHUNK_SIZE: number = 42;
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 KnowledgeChunkItem {
id: number = 0;
sourceTitle: string = '';
chunkText: string = '';
distance: number = 0;
vectorText: string = '';
createdAt: string = '';
constructor(
id: number,
sourceTitle: string,
chunkText: string,
distance: number,
vectorText: string,
createdAt: string
) {
this.id = id;
this.sourceTitle = sourceTitle;
this.chunkText = chunkText;
this.distance = distance;
this.vectorText = vectorText;
this.createdAt = createdAt;
}
}
class KnowledgeInfoItem {
label: string = '';
value: string = '';
constructor(label: string, value: string) {
this.label = label;
this.value = value;
}
}
@Entry
@Component
struct ArkDataVectorKnowledgeBaseDemo {
@State sourceTitle: string = 'ArkData 本地知识';
@State sourceText: string = 'Preferences 适合保存轻量配置。RDB 适合保存结构化业务数据。向量数据库适合保存向量化结果,并用距离排序找相似内容。';
@State queryText: string = '怎么找相似知识片段';
@State tips: string = '把一段资料切成多个 chunk,分别写入向量数据库,再按距离召回。';
@State items: KnowledgeChunkItem[] = [];
@State infoItems: KnowledgeInfoItem[] = [];
@State showInfoSheet: boolean = false;
@State vectorSupportedText: string = '待检测';
@State stageText: string = '等待初始化';
@State indexStatusText: string = '尚未创建';
private store: relationalStore.RdbStore | undefined = undefined;
aboutToAppear(): void {
this.vectorSupportedText = relationalStore.isVectorSupported() ? '支持' : '不支持';
if (relationalStore.isVectorSupported()) {
this.loadChunks();
} else {
this.items = [];
this.tips = '当前设备不支持向量数据库,页面可查看知识库流程。';
this.updateInfoItems('设备不支持向量能力');
}
}
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);
}
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_CHUNK} (` +
'id INTEGER PRIMARY KEY AUTOINCREMENT, ' +
'source_title TEXT NOT NULL, ' +
'chunk_text 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_CHUNK} USING GSDISKANN(repr COSINE)`
);
this.indexStatusText = '已创建';
} catch (err) {
const error = err as BusinessError;
this.indexStatusText = '创建失败,已降级为无索引向量查询';
console.error(`Knowledge vector index create failed: stage=${this.stageText}, ${this.errorToText(error)}`);
}
}
private splitToChunks(text: string): string[] {
const source = text.replace(/\n/g, ' ').trim();
const chunks: string[] = [];
let start = 0;
while (start < source.length) {
const end = Math.min(source.length, start + CHUNK_SIZE);
const chunk = source.substring(start, end).trim();
if (chunk.length > 0) {
chunks.push(chunk);
}
start = end;
}
return chunks;
}
private createTeachingVector(text: string): Float32Array {
const source = text.length > 0 ? text : '空文本';
let conceptScore = 0;
let actionScore = 0;
for (let index = 0; index < source.length; index++) {
const char = source.charAt(index);
const code = source.charCodeAt(index);
if (char === '库' || char === '数' || char === '向' || char === '量' || char === 'R') {
conceptScore += 2;
} else if (char === '保' || char === '查' || char === '找' || char === '写' || char === '用') {
actionScore += 2;
} else if (code % 2 === 0) {
conceptScore += 1;
} else {
actionScore += 1;
}
}
const total = Math.max(1, conceptScore + actionScore);
return Float32Array.from([conceptScore / total, actionScore / 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): void {
this.infoItems = [
new KnowledgeInfoItem('数据库名称', DB_NAME),
new KnowledgeInfoItem('表名', TABLE_CHUNK),
new KnowledgeInfoItem('向量字段', `repr floatvector(${VECTOR_DIMENSION})`),
new KnowledgeInfoItem('向量索引', `${VECTOR_INDEX} / GSDISKANN / COSINE / ${this.indexStatusText}`),
new KnowledgeInfoItem('向量来源', VECTOR_SOURCE_TEXT),
new KnowledgeInfoItem('Embedding', EMBEDDING_SCOPE_TEXT),
new KnowledgeInfoItem('只读模式', 'false'),
new KnowledgeInfoItem('chunk 长度', `${CHUNK_SIZE} 字符`),
new KnowledgeInfoItem('向量能力', this.vectorSupportedText),
new KnowledgeInfoItem('初始化阶段', this.stageText),
new KnowledgeInfoItem('当前展示数', `${this.items.length}`),
new KnowledgeInfoItem('最近动作', action)
];
}
private readRows(resultSet: relationalStore.ResultSet, hasDistance: boolean): KnowledgeChunkItem[] {
const rows: KnowledgeChunkItem[] = [];
const idIndex = resultSet.getColumnIndex('id');
const titleIndex = resultSet.getColumnIndex('source_title');
const chunkIndex = resultSet.getColumnIndex('chunk_text');
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 KnowledgeChunkItem(
resultSet.getLong(idIndex),
resultSet.getString(titleIndex),
resultSet.getString(chunkIndex),
distance,
this.vectorToText(vector),
resultSet.getString(createdAtIndex)
));
}
return rows;
}
private async loadChunks(action: string = '读取知识片段'): Promise<void> {
let resultSet: relationalStore.ResultSet | undefined = undefined;
if (!relationalStore.isVectorSupported()) {
this.items = [];
this.vectorSupportedText = '不支持';
this.tips = '当前设备不支持向量数据库,无法创建 floatvector 表。';
this.updateInfoItems('设备不支持向量能力');
return;
}
try {
const store = await this.getStore();
this.stageText = '正在读取知识片段表';
resultSet = await store.querySql(
`SELECT id, source_title, chunk_text, repr, created_at FROM ${TABLE_CHUNK} ORDER BY id DESC`
);
this.items = this.readRows(resultSet, false);
this.stageText = '读取完成';
this.updateInfoItems(action);
} catch (err) {
const error = err as BusinessError;
if (this.isCapabilityNotSupported(error)) {
this.markVectorTableUnavailable('向量表不可用');
promptAction.showToast({ message: '向量表不可用' });
console.error(`Knowledge vector load failed: stage=${this.stageText}, ${this.errorToText(error)}`);
return;
}
this.items = [];
this.tips = `读取失败:阶段=${this.stageText},${this.errorToText(error)}。`;
this.updateInfoItems('读取失败');
promptAction.showToast({ message: '读取失败' });
console.error(`Knowledge vector load failed: stage=${this.stageText}, ${this.errorToText(error)}`);
} finally {
if (resultSet !== undefined) {
resultSet.close();
}
}
}
private async saveKnowledge(): Promise<void> {
try {
if (!relationalStore.isVectorSupported()) {
this.tips = '当前设备不支持向量数据库,无法保存向量字段。';
this.updateInfoItems('写入被跳过');
promptAction.showToast({ message: '设备不支持向量能力' });
return;
}
const store = await this.getStore();
const title = this.sourceTitle.length > 0 ? this.sourceTitle : '未命名资料';
const chunks = this.splitToChunks(this.sourceText.length > 0 ? this.sourceText : '空内容');
const now = new Date().toString();
for (let index = 0; index < chunks.length; index++) {
const chunk = chunks[index];
const vector = this.createTeachingVector(`${title}\n${chunk}`);
const values: relationalStore.ValuesBucket = {
source_title: title,
chunk_text: chunk,
repr: vector,
created_at: now
};
await store.insert(TABLE_CHUNK, values);
}
await this.loadChunks('写入知识库');
this.tips = `写入成功:${chunks.length} 个 chunk 已保存到向量数据库。`;
promptAction.showToast({ message: '知识库已写入' });
} catch (err) {
const error = err as BusinessError;
if (this.isCapabilityNotSupported(error)) {
this.markVectorTableUnavailable('写入被跳过');
promptAction.showToast({ message: '向量表不可用' });
console.error(`Knowledge vector save failed: stage=${this.stageText}, ${this.errorToText(error)}`);
return;
}
this.tips = `写入失败:阶段=${this.stageText},${this.errorToText(error)}。`;
promptAction.showToast({ message: '写入失败' });
console.error(`Knowledge vector save failed: stage=${this.stageText}, ${this.errorToText(error)}`);
}
}
private async searchKnowledge(): Promise<void> {
let resultSet: relationalStore.ResultSet | undefined = undefined;
if (!relationalStore.isVectorSupported()) {
this.tips = '当前设备不支持向量数据库,无法执行向量距离检索。';
this.updateInfoItems('检索被跳过');
promptAction.showToast({ message: '设备不支持向量能力' });
return;
}
try {
const store = await this.getStore();
const queryVector = this.createTeachingVector(this.queryText);
this.stageText = '正在执行知识库向量检索';
resultSet = await store.querySql(
`SELECT id, source_title, chunk_text, repr, created_at, repr <=> ? AS distance FROM ${TABLE_CHUNK} ` +
'ORDER BY repr <=> ? LIMIT ?',
[queryVector, queryVector, RESULT_LIMIT]
);
this.items = this.readRows(resultSet, true);
this.stageText = '检索完成';
this.updateInfoItems('查询知识库');
this.tips = `召回完成:查询向量 ${this.vectorToText(queryVector)},distance 越小越接近。`;
this.showInfoSheet = true;
} catch (err) {
const error = err as BusinessError;
if (this.isCapabilityNotSupported(error)) {
this.markVectorTableUnavailable('检索被跳过');
promptAction.showToast({ message: '向量表不可用' });
console.error(`Knowledge vector search failed: stage=${this.stageText}, ${this.errorToText(error)}`);
return;
}
this.tips = `检索失败:阶段=${this.stageText},${this.errorToText(error)}。`;
promptAction.showToast({ message: '检索失败' });
console.error(`Knowledge vector search failed: stage=${this.stageText}, ${this.errorToText(error)}`);
} finally {
if (resultSet !== undefined) {
resultSet.close();
}
}
}
private async clearKnowledge(): Promise<void> {
try {
if (!relationalStore.isVectorSupported()) {
this.items = [];
this.tips = '当前设备不支持向量数据库,没有可清空的向量表。';
this.updateInfoItems('清空被跳过');
return;
}
const store = await this.getStore();
await store.executeSql(`DELETE FROM ${TABLE_CHUNK}`);
await this.loadChunks('清空知识库');
this.tips = '知识库已清空。';
promptAction.showToast({ message: '已清空' });
} catch (err) {
const error = err as BusinessError;
if (this.isCapabilityNotSupported(error)) {
this.markVectorTableUnavailable('清空被跳过');
promptAction.showToast({ message: '向量表不可用' });
console.error(`Knowledge vector clear failed: stage=${this.stageText}, ${this.errorToText(error)}`);
return;
}
this.tips = `清空失败:阶段=${this.stageText},${this.errorToText(error)}。`;
promptAction.showToast({ message: '清空失败' });
console.error(`Knowledge vector clear failed: stage=${this.stageText}, ${this.errorToText(error)}`);
}
}
@Builder
private infoSheet() {
Column({ space: 14 }) {
Text('本地知识库信息')
.width('100%')
.fontSize(22)
.fontWeight(700)
.fontColor('#0F172A')
Text('每个 chunk 独立保存向量,查询时使用 ORDER BY repr <=> ? LIMIT。')
.width('100%')
.fontSize(13)
.fontColor('#64748B')
Column({ space: 8 }) {
ForEach(this.infoItems, (item: KnowledgeInfoItem) => {
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: KnowledgeInfoItem) => item.label)
}
.width('100%')
Button('关闭')
.width('100%')
.height(44)
.fontSize(16)
.backgroundColor('#7C2D12')
.onClick(() => {
this.showInfoSheet = false;
})
}
.width('100%')
.padding({ left: 20, right: 20, top: 12, bottom: 20 })
}
build() {
Scroll() {
Column({ space: 18 }) {
Row() {
Text('返回')
.fontSize(14)
.fontColor('#7C2D12')
.onClick(() => {
router.back();
})
Blank()
}
.width('100%')
Text('本地知识库')
.width('100%')
.fontSize(28)
.fontWeight(700)
.fontColor('#182431')
Text('把长文本切成片段,逐条写入向量字段,再按数据库侧余弦距离召回。')
.width('100%')
.fontSize(14)
.fontColor('#56616F')
Text(`当前向量能力:${this.vectorSupportedText}`)
.width('100%')
.fontSize(13)
.fontColor(this.vectorSupportedText === '支持' ? '#7C2D12' : '#B45309')
TextInput({ placeholder: '资料标题', text: this.sourceTitle })
.height(44)
.fontSize(16)
.backgroundColor('#FFF7ED')
.fontColor('#0F172A')
.onChange((value: string) => {
this.sourceTitle = value;
})
TextArea({ placeholder: '粘贴一段资料', text: this.sourceText })
.height(120)
.fontSize(15)
.backgroundColor('#FFF7ED')
.fontColor('#0F172A')
.onChange((value: string) => {
this.sourceText = value;
})
TextInput({ placeholder: '输入问题', text: this.queryText })
.height(44)
.fontSize(16)
.backgroundColor('#FFEDD5')
.fontColor('#0F172A')
.onChange((value: string) => {
this.queryText = value;
})
Row({ space: 10 }) {
Button('写入')
.layoutWeight(1)
.height(44)
.fontSize(15)
.backgroundColor('#7C2D12')
.onClick(() => {
this.saveKnowledge();
})
Button('检索')
.layoutWeight(1)
.height(44)
.fontSize(15)
.fontColor('#0F172A')
.backgroundColor('#FED7AA')
.onClick(() => {
this.searchKnowledge();
})
Button('查看')
.layoutWeight(1)
.height(44)
.fontSize(15)
.fontColor('#0F172A')
.backgroundColor('#FFEDD5')
.onClick(() => {
this.loadChunks('查看知识库');
this.showInfoSheet = true;
})
Button('清空')
.layoutWeight(1)
.height(44)
.fontSize(15)
.fontColor('#0F172A')
.backgroundColor('#CBD5E1')
.onClick(() => {
this.clearKnowledge();
})
}
.width('100%')
Column({ space: 8 }) {
ForEach(this.items, (item: KnowledgeChunkItem) => {
Column({ space: 5 }) {
Text(item.sourceTitle)
.width('100%')
.fontSize(17)
.fontWeight(700)
.fontColor('#0F172A')
Text(item.chunkText)
.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: KnowledgeChunkItem) => `${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.showInfoSheet, this.infoSheet(), {
height: SheetSize.MEDIUM,
dragBar: true,
showClose: true,
onDisappear: () => {
this.showInfoSheet = false;
}
})
}
}
- 点赞
- 收藏
- 关注作者
评论(0)