Harmony os ------ Data Augmentation Kit 知识问答完整示例实战拆解(从 0 跑通流式 RAG)
这篇就是把 "完整示例代码" 好好理一遍,记录一下我自己在 HarmonyOS 上用 Data Augmentation Kit 跑通 知识问答 + 流式 RAG 的全过程。
文档里的东西偏"说明书风格",这一篇我就按"项目视角 + 文件视角"来拆:
从工程启动 → 知识加工 → 创建 RagSession → 问答 UI → 流式大模型交互 ,一条链子说清楚。鸿蒙开发者第四期活动
一、这个示例到底干了什么?
一句话概括整个 DEMO 做的事:
从内置 JSON 构造一张 email 表 → 自动触发知识加工生成知识库 → 创建 RagSession → 在页面上输入问题 → 调用 streamRun 做流式问答 → 把思考过程 + 最终答案一边生成一边展示出来。
整个流程大概是这样:
- App 启动时:
- 初始化数据库(
email表); - 从
sourceData.json把模拟邮件数据插入数据库; - 根据
knowledge_schema.json自动做"知识加工",生成向量库 & 倒排库; - 创建
RagSession并丢到AppStorage里。
- 初始化数据库(
- 页面
Index.ets:- 有一个输入框(问题)、一个"streamRun" 按钮、一个结果区;
- 点击按钮 → 调用
session.streamRun(); - Data Augmentation Kit 去检索知识库 + 调你实现的
MyChatLLM; - 通过 callback 流式推回 THOUGHT(思考) 和 ANSWER(答案),UI 里实时拼接显示。
下面按文件来拆每个角色。
二、工程关键文件总览
这个完整示例的核心文件可以简单理解成这张表:
| 角色 | 文件 | 主要职责 |
|---|---|---|
| 生命周期入口 | EntryAbility.ets |
启动时建表、插数据、创建 RagSession |
| 数据源构造 | SetUp.ets |
连接数据库、建 email 表、插入 JSON 数据 |
| LLM HTTP 工具 | HttpUtils.ets |
和大模型流式交互(ModelArts 示例) |
| LLM 适配层 | MyChatLlm.ets |
继承 ChatLLM,封装 streamChat |
| RAG 配置 | Config.ets |
组装 retrievalConfig / retrievalCondition / rag.Config |
| UI 页面 | pages/Index.ets |
输入问题、点击按钮、展示 thought + answer |
| 知识加工 schema | rawfile/arkdata/knowledge/knowledge_schema.json |
定义如何从 email 表生成知识库 |
| 模拟数据源 | rawfile/sourceData.json |
一封"手机优惠政策"的测试邮件 |
搞清楚这几个文件之间的关系,基本就理解了整个 Demo 的结构。
三、EntryAbility:启动就把"底子"铺好
文件: src/main/ets/entryability/EntryAbility.ets
这个 Ability 做了三件大事:
- 加载首页 UI
- 初始化数据库 & 插入数据
- 创建 RagSession,并在销毁时负责善后
核心逻辑在 onWindowStageCreate:
onWindowStageCreate(windowStage: window.WindowStage): void {
// 1. 加载首页页面
windowStage.loadContent('pages/Index', (err) => { ... });
// 2. 把 context 丢进 AppStorage,后面大家都从这里拿
AppStorage.setOrCreate<common.UIAbilityContext>('Context', this.context);
// 3. 初始化数据表 + 插入数据
let setUp: SetUp = new SetUp();
setUp.initTable().then(() => {
setUp.insertData();
AppStorage.setOrCreate<SetUp>('SetUpObject', setUp);
});
// 4. 创建 RagSession
let config: Config = new Config();
rag.createRagSession(this.context, config.getRAGConfig()).then((data) => {
AppStorage.setOrCreate<rag.RagSession>('RagSessionObject', data);
}).catch((err: BusinessError) => {
...
});
}
销毁时记得把东西关掉:
onWindowStageDestroy(): void {
const session = AppStorage.get<rag.RagSession>('RagSessionObject') as rag.RagSession;
session?.close().catch(() => {
hilog.error(DOMAIN, 'testTag', 'close rag session failed');
});
const setup = AppStorage.get<SetUp>('SetUpObject') as SetUp;
setup?.closeStore();
}
小结:
EntryAbility负责"一次性初始化":表结构、数据、RagSession;AppStorage是全局共享的"上下文地图",后面页面、配置类都从这里拿Context/RagSession/SetUp。
四、SetUp:从 JSON 搞到数据库 & 触发知识加工
文件: src/main/ets/entryability/SetUp.ets
这个类的关键词有两个:
- 源数据库配置里的
enableSemanticIndex: true - 以及和
knowledge_schema.json一一对应的storeName
1. 配置源数据库(触发知识加工的关键)
storeName: string = 'testmail_store.db'; // 要和 knowledge_schema.json 里的 dbName 一致
storeConfig: relationalStore.StoreConfig = {
name: this.storeName,
securityLevel: relationalStore.SecurityLevel.S3,
enableSemanticIndex: true, // ⭐ 必须为 true 才会触发知识加工
tokenizer: relationalStore.Tokenizer.CUSTOM_TOKENIZER
};
enableSemanticIndex: true+ 正确的knowledge_schema.json= 系统会自动根据 schema 对数据做知识加工,生成向量表 & 倒排表。
2. 建表
const createTableSql = `
CREATE TABLE IF NOT EXISTS email(
id integer primary key,
subject text,
content text,
image_text text,
attachment_names text,
inline_files text,
sender text,
receivers text,
received_date text
);
`;
await tmpStore?.execute(createTableSql, 0, undefined);
3. 从 sourceData.json 插入模拟数据
这一段逻辑就是:
- 遍历 rawfile 目录下的 file;
- 找到
sourceData*.json; - 读取文件 → 转成字符串 →
JSON.parse; - 把每封"邮件"映射成 email 表的一行,执行
insert。
重点看几处小处理:
const rawFileData = await context.resourceManager.getRawFileContent(file);
const fileData: string = buffer.from(rawFileData).toString();
const resultObjArr = JSON.parse(fileData) as Array<object>;
...
let sender: string = jsonObj?.['sender_name'];
if (!sender || sender.length == 0) {
sender = 'undefined';
}
const receiverStr: string = JSON.stringify(jsonObj['to']);
const formattedDateStr: string = jsonObj?.['received_time']?.replace(' ', 'T');
let received_date = Date.parse(formattedDateStr);
if (!received_date || Number.isNaN(received_date)) {
received_date = 0;
}
let subject: string = jsonObj?.['subject']?.replace(/'/g, '');
let doc: string = jsonObj?.['body']?.replace(/'/g, '');
let sql = `insert or replace into email VALUES(${dataIndex}, '${subject}', '${doc}', '',` +
` '', '', '${sender}', '${receiverStr}', '${received_date}');`
可以看到:
subject/body里的单引号都被replace(/'/g, '')处理了一下,避免拼 SQL 炸掉;received_time被转成时间戳received_date存到表里;to被 JSON 序列化后存入receivers。
实战场景里,这些数据肯定不是从 JSON 读,而是从 UI / 服务器来,这里就是个"知识加工入口"的演示。
五、HttpUtils:和大模型流式聊聊天
文件: src/main/ets/entryability/HttpUtils.ets
这是一个很纯粹的 大模型 HTTP 工具类,做几件事:
- 组装 HTTP 请求参数(带上
model、temperature、API Key等); - 使用
http.requestInStream发起流式请求; - 注册
dataReceive回调,把流式数据丢给上层。
1. 请求配置
initOption(question: string) {
let option: http.HttpRequestOptions = {
method: http.RequestMethod.POST,
header: {
'Content-Type': 'application/json',
'Authorization': `Bearer ****replace your API key in here****`
},
extraData: {
stream: true,
temperature: 0.1,
max_tokens: 1000,
frequency_penalty: 1,
model: 'qwen3-235b-a22b',
top_p: 0.1,
presence_penalty: -1,
messages: JSON.parse(question),
chat_template_kwargs: {
enable_thinking: false
}
}
};
return option;
}
注意:
- 这里的
model/url/API Key都是示例,实际要换成你自己在 ModelArts 或其他平台上的配置;messages是标准 Chat 格式:[{ role, content}, ...],外面会把问题封成 JSON 字符串传进来。
2. 流式请求 + 回调注册
async requestInStream(question: string) {
if (!this.httpRequest) {
this.httpRequest = http.createHttp();
}
this.httpRequest.requestInStream(this.url, this.initOption(question)).catch(...);
this.isFinished = false;
}
on(callback: Callback<ArrayBuffer>) {
if (!this.httpRequest) {
this.httpRequest = http.createHttp();
}
this.httpRequest.on('dataReceive', callback);
}
cancel() {
this.httpRequest?.off('dataReceive');
this.httpRequest?.destroy();
this.httpRequest = undefined;
}
这里没有做"半路中断某一条请求"的精细化处理,简单暴力地 destroy();对 demo 来说够用。
别忘了在
module.json5里加网络权限:
"name": "ohos.permission.INTERNET",否则这里的 HTTP 全挂。
六、MyChatLlm:把大模型结果适配成 LLMStreamAnswer
文件: src/main/ets/entryability/MyChatLlm.ets
这个类是整个 RAG 链路的 大脑适配层:
- 继承
rag.ChatLLM; - 实现
streamChat&cancel; - 把 HTTP 层返回的二进制流解析成
LLMStreamAnswer。
1. 流式数据解析 parseLLMResponse
这个示例假定大模型返回格式类似 OpenAI 风格的 SSE(data: {...} 形式),解析大概这样:
function parseLLMResponse(data: ArrayBuffer): rag.LLMStreamAnswer | undefined {
let decoder = util.TextDecoder.create(`"utf-8"`);
let str = decoder.decodeToString(new Uint8Array(data));
hilog.info(0, TAG, str);
let chunk = '';
let isFinished: boolean = (str.length < 20);
for (let resultStr of str.split('data:')) {
if (resultStr.trim() == ('[DONE]')) {
isFinished = true;
break;
}
if (resultStr.trim().length == 0) {
continue;
}
try {
let obj = JSON.parse(resultStr.trim());
if ((obj as object)?.['choices'].length === 0) {
continue;
}
if ((obj as object)?.['choices'][0]?.['delta']?.['reasoning_content']) {
chunk += (obj as object)?.['choices'][0]['delta']['reasoning_content'];
} else if ((obj as object)?.['choices'][0]?.['delta']?.['content']) {
chunk += (obj as object)?.['choices'][0]['delta']['content'];
}
} catch (err) {
hilog.error(0, TAG, `Parse LLM response failed, resultStr: ${resultStr}`);
}
}
let answer: rag.LLMStreamAnswer = {
isFinished,
chunk
};
return answer;
}
这里有两个点:
- 模型可能同时返回 推理过程(
reasoning_content) 和 最终内容(content),示例里两个字段都兼容; - 当收到
[DONE]或者数据很短时,就认为这轮回答结束。
2. 实现 streamChat
export default class MyChatLLM extends rag.ChatLLM {
async streamChat(query: string, callback: Callback<rag.LLMStreamAnswer>): Promise<rag.LLMRequestInfo> {
let ret: rag.LLMRequestStatus = rag.LLMRequestStatus.LLM_SUCCESS;
try {
let dataCallback = async (data: ArrayBuffer) => {
const answer = parseLLMResponse(data);
if (!answer) return;
HttpUtils.isFinished = answer.isFinished;
callback(answer);
};
HttpUtils.on(dataCallback);
HttpUtils.requestInStream(query);
} catch (err) {
hilog.error(0, TAG, `Request LLM failed, error code: ${err.code}, error message: ${err.message}`);
ret = rag.LLMRequestStatus.LLM_REQUEST_ERROR;
}
return {
chatId: 0,
status: ret
};
}
cancel(chatId: number): void {
hilog.info(0, TAG, `The request for the large model has been canceled. chatId: ${chatId}`);
HttpUtils.cancel();
}
}
小结:
- 对 Data Augmentation Kit 来说,它完全不关心你用的是什么模型,只要你把 问题 JSON 字符串 → LLMStreamAnswer 流 这件事实现好了就行;
- 和不同大模型打交道时,只要换
HttpUtils&parseLLMResponse的逻辑就可以了。
七、Config:RAG 的"大配置中心"
文件: src/main/ets/entryability/Config.ets
Config 做的是:组装一份 rag.Config 给 createRagSession 用。
1. RetrievalConfig:告诉系统"知识库在哪"
getRetrievalConfig() {
let storeConfigVector: relationalStore.StoreConfig = {
name: 'testmail_store_vector.db',
securityLevel: relationalStore.SecurityLevel.S3,
vector: true
};
let storeConfigInvIdx: relationalStore.StoreConfig = {
name: 'testmail_store.db',
securityLevel: relationalStore.SecurityLevel.S3,
tokenizer: relationalStore.Tokenizer.CUSTOM_TOKENIZER
};
let context = AppStorage.get<common.UIAbilityContext>('Context') as common.UIAbilityContext;
let channelConfigVector: retrieval.ChannelConfig = {
channelType: retrieval.ChannelType.VECTOR_DATABASE,
context,
dbConfig: storeConfigVector
};
let channelConfigInvIdx: retrieval.ChannelConfig = {
channelType: retrieval.ChannelType.INVERTED_INDEX_DATABASE,
context,
dbConfig: storeConfigInvIdx
};
let retrievalConfig: retrieval.RetrievalConfig = {
channelConfigs: [channelConfigInvIdx, channelConfigVector]
};
return retrievalConfig;
}
注意:
- 向量库文件名里加了
_vector后缀;- 倒排库就是原始库;
- 两个通道最终都写入
RetrievalConfig.channelConfigs中。
2. RetrievalCondition:多路召回 + 重排逻辑
let recallConditionInvIdx: retrieval.InvertedIndexRecallCondition = {
ftsTableName: 'email_inverted',
fromClause: 'select email_inverted.reference_id as rowid, * from email INNER JOIN email_inverted ON email.id = email_inverted.reference_id',
primaryKey: ['chunk_id'],
responseColumns: ['reference_id', 'chunk_id', 'chunk_source', 'chunk_text', 'subject', 'image_text',
'attachment_names'],
deepSize: 500,
recallName: 'invertedvectorRecall',
};
let floatArray = new Float32Array(128).fill(0.1);
let vectorQuery: retrieval.VectorQuery = {
column: 'repr',
value: floatArray,
similarityThreshold: 0.1
};
let recallConditionVector: retrieval.VectorRecallCondition = {
vectorQuery,
fromClause: 'email_vector',
primaryKey: ['id'],
responseColumns: ['reference_id', 'chunk_id', 'chunk_source', 'repr'],
recallName: 'vectorRecall',
deepSize: 500
};
let rerankMethod: retrieval.RerankMethod = {
rerankType: retrieval.RerankType.RRF,
isSoftmaxNormalized: true,
};
let retrievalCondition: retrieval.RetrievalCondition = {
rerankMethod,
recallConditions: [recallConditionInvIdx, recallConditionVector],
resultCount: 5
};
几点说明:
- 倒排召回:
fromClause用 INNER JOIN 把原始表email和倒排表连起来; - 向量召回:只查
email_vector表; responseColumns必须来自fromClause的结果列,不然会报错;Float32Array(128).fill(0.1)在示例中只是一个占位向量,真实场景下应该用 问题的向量表示;- 重排采用
RRF(Reciprocal Rank Fusion)。
3. 拼成 RAGConfig
getRAGConfig() {
let retrievalConfig = this.getRetrievalConfig();
let retrievalCondition = this.getRetrivalCondition();
let config: rag.Config = {
llm: new MyChatLlm(),
retrievalConfig,
retrievalCondition
};
return config;
}
八、Index 页面:一个按钮触发一次完整 RAG 流程
文件: src/main/ets/pages/Index.ets
UI 很简单,但已经包含了一个"最小可用"的问答界面:
- 上面一个 TextArea 输入问题;
- 中间一个 Button;
- 下面一个区域展示模型的"思考过程 + 最终回答"。
关键逻辑在 Button 的 onClick:
Button('streamRun')
.onClick(async () => {
// 1. 拿到 RagSession
let session: rag.RagSession = AppStorage.get<rag.RagSession>('RagSessionObject') as rag.RagSession;
// 2. 配置输出哪些流
let config: rag.RunConfig = {
answerTypes: [rag.StreamType.THOUGHT, rag.StreamType.ANSWER]
};
this.thoughtStr = '';
this.answerStr = '';
// 3. 发起提问(流式)
session.streamRun(this.inputStr, config, ((err: BusinessError, stream: rag.Stream) => {
if (err) {
this.answerStr = `streamRun inner failed. code is ${err.code}, message is ${err.message}`;
} else {
switch (stream.type) {
case rag.StreamType.THOUGHT:
this.thoughtStr += stream.answer.chunk;
break;
case rag.StreamType.ANSWER:
this.answerStr += stream.answer.chunk;
break;
case rag.StreamType.REFERENCE:
default:
hilog.info(0, 'Index', `streamRun msg: ${JSON.stringify(stream)}`);
}
}
})).catch((e: BusinessError) => {
this.answerStr = `streamRun failed. code is ${e.code}, message is ${e.message}`;
});
});
配合下面的展示区:
Text(this.thoughtStr)
.fontSize(12)
.fontColor(Color.Gray)
...
Text(this.answerStr)
.padding(8)
...
实际体验:
- 点击按钮之后,可以看到灰色的小字(THOUGHT)一点点刷出来;
- 下面的答案区域(ANSWER)同步往后拼;
- 这就是一个标准的 "流式 RAG 问答体验"。
九、knowledge_schema.json:告诉系统怎么"加工知识"
路径: src/main/resources/rawfile/arkdata/knowledge/knowledge_schema.json
这个文件就是知识加工的配置书,核心字段如下:
{
"knowledgeSource": [{
"version": 1,
"dbName": "testmail_store.db",
"tables": [{
"tableName": "email",
"referenceFields": ["id"],
"knowledgeFields": [
{ "columnName": "subject", "type": ["Text"] },
{ "columnName": "content", "type": ["Text"] },
{ "columnName": "image_text", "type": ["Text"] },
{ "columnName": "attachment_names", "type": ["Text"] },
{
"columnName": "inline_files",
"type": ["Json"],
"parser": [
{
"type": "File",
"path": "$[*].uri"
}
]
},
{ "columnName": "sender", "type": ["Scalar"], "description": "sender" },
{ "columnName": "receivers", "type": ["Scalar"], "description": "receivers" },
{ "columnName": "received_date", "type": ["Scalar"], "description": "received_date" }
]
}]
}]
}
关键要点:
dbName必须和SetUp.storeName保持一致;referenceFields是主键/引用字段,这里用的是id;knowledgeFields告诉系统:- 哪些列参与向量化(
Text); - 哪些作为结构信息存在(
Scalar/Json); - 对 Json 类型如何解析出文件路径(
parser)。
- 哪些列参与向量化(
配合 enableSemanticIndex: true,系统就会自动根据这个 schema 对 email 表做知识加工,生成对应的倒排表 + 向量表。
十、sourceData.json:模拟的一封"优惠政策邮件"
路径: src/main/resources/rawfile/sourceData.json
里面就是一封结构化的"邮件":
subject:邮件标题sender_name、sender_emailreceived_timerecipients/to/cc/bccbody:正文(会被存入content列)
SetUp 在 insertData() 里会把这些字段映射到 email 表对应列,后面知识加工就以这张表为源头。
十一、从启动到问答的一次完整时序回顾
最后用步骤再串一遍整个调用链:
- 应用启动 →
EntryAbility.onWindowStageCreate:- 设置首页
Index; - 把
context存到AppStorage; - 调
SetUp.initTable()建 email 表; - 调
SetUp.insertData()插入sourceData.json中的模拟数据; - 由于
enableSemanticIndex: true+ 正确的knowledge_schema.json→ 系统开始后台做知识加工,生成testmail_store_vector.db/email_inverted等; - 调
Config.getRAGConfig()组装rag.Config; - 调
rag.createRagSession(...)创建RagSession,丢到AppStorage。
- 设置首页
- 用户打开首页
Index:- 输入问题(默认示例里是"知识问答开发指南完整示例代码");
- 点击
streamRun按钮。
Index里:- 从
AppStorage拿RagSession; - 配
RunConfig.answerTypes = [THOUGHT, ANSWER]; - 调
session.streamRun(inputStr, config, callback)。
- 从
- Data Augmentation Kit 内部:
- 根据
retrievalConfig/retrievalCondition对知识库做多路召回 + 重排; - 构造 prompt + 上下文;
- 调用你实现的
MyChatLLM.streamChat()。
- 根据
MyChatLLM:- 用
HttpUtils.requestInStream()发起 HTTP 流式请求; - 注册
dataReceive回调,每次收到一段数据 →parseLLMResponse→ 得到LLMStreamAnswer; - 调
callback(answer)把结果抛给 RAG / UI。
- 用
Index页面里的 callback:- 根据
stream.type把内容累加到thoughtStr/answerStr; - UI 实时刷新出一行行文字。
- 根据
- 应用退出时:
- 在
onWindowStageDestroy中关闭RagSession和数据库连接。
- 在
十二、踩坑 & 小结
实际在看这个例子的时候,我自己觉得需要特别注意的几个点:
- 网络权限别忘了 :
ohos.permission.INTERNET不配好,HttpUtils 全部白写。 dbName&storeName必须一致:不然知识加工直接不生效,后面检索会查不到表。enableSemanticIndex: true必须打开:这是触发知识加工的"电源开关"。RetrievalCondition.responseColumns一定要对应fromClause里能查到的列:一旦写错列名,会直接检索失败。- 向量维度要和知识库一致 :示例里是
Float32Array(128),真实项目里不要偷懒硬写,应该根据模型输出向量维度来定。 parseLLMResponse强依赖模型返回格式:你换模型的时候,这块几乎肯定要跟着改。
这篇就当是我自己对 Harmony os + Data Augmentation Kit 知识问答完整示例 的一次"拆解+复盘"。
后面我准备在这个基础上再做两件事:
- 把邮件知识库换成我自己的 学习笔记 / 项目文档,做一个"本地知识小助手";
- 把 UI 做成多轮对话的聊天框,顺便接入不同大模型试试效果。
到时候再写一篇"实践篇"接着这篇往下更新,如果你也在玩鸿蒙 RAG,可以一起交流 😄