前言
纯端侧 LLM 答不了"我们公司今年报销政策是啥"——因为模型知识停在训练截止日。RAG(Retrieval-Augmented Generation)通过"先检索本地知识库、再让 LLM 回答"的流程,把私有数据接入 LLM。这一篇拆解:如何在 Android 工程里搭一个能用的端侧 RAG。
一、RAG 在 Android 上的完整链路
┌──────────────┐ ┌────────────┐ ┌──────────────┐
│ 用户问题 │───▶│ Embedding │───▶│ 向量数据库 │
└──────────────┘ └────────────┘ └──────────────┘
│ │
▼ ▼
┌────────────┐ ┌────────────┐
│ Top-K 召回 │◀───│ 余弦相似度 │
└────────────┘ └────────────┘
│
▼
┌────────────┐ ┌────────────┐
│ Prompt 组装 │───▶│ LLM 推理 │
└────────────┘ └────────────┘
│
▼
┌────────────┐
│ 输出答案 │
└────────────┘
整套流程在 Android 上跑得动,关键在于每个环节都选对模型。
二、端侧 Embedding 模型选择
2.1 选型标准
2.2 主流选择
中文场景推荐 bge-small-zh-v1.5,速度与精度平衡最佳。
2.3 在 Android 上跑 Embedding
用 LiteRT(TensorFlow Lite)或 ONNX Runtime:
// 使用 ONNX Runtime Mobile
val env = OrtEnvironment.getEnvironment()
val session = env.createSession("bge-small-zh.onnx",
OrtSession.SessionOptions())
fun embed(text: String): FloatArray {
val tokenizer = ChineseTokenizer() // 自己实现或用 JNIbert
val inputIds = tokenizer.encode(text, maxLen = 64)
val tensor = OnnxTensor.createTensor(env,
LongBuffer.wrap(inputIds), longArrayOf(1, inputIds.size.toLong()))
val output = session.run(mapOf("input_ids" to tensor))
val emb = (output.get(0).value as Array<FloatArray>)[0]
return normalize(emb)
}
fun normalize(v: FloatArray): FloatArray {
val norm = sqrt(v.map { it * it }.sum())
return v.map { it / norm }.toFloatArray()
}
注意:Tokenizer 才是大头。建议用 ONNX 模型自带的 tokenization 工具链,不用自己写。
三、向量数据库:Android 端的选择
3.1 三种主流方案
中等 App 推荐 SQLite + 手动相似度计算——简单、零依赖。
3.2 SQLite 向量化存储
CREATE TABLE documents (
id INTEGER PRIMARY KEY,
chunk_id TEXT UNIQUE,
content TEXT,
embedding BLOB, -- 存 512 维 float32 = 2KB
source TEXT,
updated_at INTEGER
);
CREATE INDEX idx_updated ON documents(updated_at);
// 插入
fun insertDoc(chunkId: String, content: String, embedding: FloatArray, source: String) {
val bytes = ByteBuffer.allocate(embedding.size * 4)
.order(ByteOrder.LITTLE_ENDIAN)
embedding.forEach { bytes.putFloat(it) }
db.execSQL("INSERT INTO documents VALUES (?, ?, ?, ?, ?, ?)",
arrayOf(null, chunkId, content, bytes.array(), source, System.currentTimeMillis()))
}
// 检索
fun searchTopK(query: FloatArray, k: Int = 5): List<Pair<String, Float>> {
val candidates = mutableListOf<Pair<String, Float>>()
db.rawQuery("SELECT chunk_id, content, embedding FROM documents", null).use { c ->
while (c.moveToNext()) {
val content = c.getString(1)
val bytes = c.getBlob(2)
val vec = byteToFloat(bytes)
val sim = cosineSimilarity(query, vec)
candidates.add(content to sim)
}
}
return candidates.sortedByDescending { it.second }.take(k)
}
100K 条数据量下,纯 CPU 余弦相似度检索在 200ms 以内。更大规模用 Hnswlib ANN。
3.3 Hnswlib 简易接入
// jni/hnsw_wrapper.cpp
#include <hnswlib/hnswlib.h>
class HnswIndex {
hnswlib::HierarchicalNSW<float>* index;
hnswlib::SpaceInterface<float>* space;
public:
void init(int dim, int maxItems) {
space = new hnswlib::L2Space(dim);
index = new hnswlib::HierarchicalNSW<float>(space, maxItems);
}
void add(int id, float* vec) {
index->addPoint(vec, id);
}
std::vector<int> search(float* query, int k) {
auto result = index->searchKnn(query, k);
std::vector<int> ids;
while (!result.empty()) {
ids.push_back(result.top().second);
result.pop();
}
return ids;
}
};
JNI 编译后 Java 端通过 System.loadLibrary("hnswjni") 调用。中等 App 一般用不到,10W+ 条数据再考虑。
四、文档切片:分块策略决定召回质量
最容易被忽视的一步。常见策略:
代码示例:
object DocumentChunker {
fun chunk(text: String, source: String): List<DocChunk> {
// 按段落切
val paragraphs = text.split(Regex("\n\n+"))
val chunks = mutableListOf<DocChunk>()
var cur = StringBuilder()
paragraphs.forEach { p ->
if (cur.length + p.length > 300) {
if (cur.isNotEmpty()) {
chunks.add(DocChunk(cur.toString(), source))
}
cur = StringBuilder()
}
cur.append(p).append("\n")
}
if (cur.isNotEmpty()) chunks.add(DocChunk(cur.toString(), source))
return chunks
}
}
固定窗口太机械;段落级适合 Markdown;Q&A 适合客服场景。
五、Prompt 组装
召回完成后,要把 top-K chunks 组装成 prompt:
fun buildPrompt(question: String, docs: List<String>): String {
val context = docs.joinToString("\n\n") {
"<doc>\n$it\n</doc>"
}
return """
你是公司内部知识助手。基于下面的资料回答问题;如果资料中没有答案,说"我不知道"。
$context
问题:$question
答案:""".trimIndent()
}
val prompt = buildPrompt("出差住宿标准是什么?", topDocs)
val answer = llm.generateResponse(prompt)
核心约束:"资料中没有就说不知道"——避免 hallucination。
六、完整工程结构
app/
├─ data/
│ ├─ rag/
│ │ ├─ Embedder.kt # Embedding 推理
│ │ ├─ VectorStore.kt # SQLite 向量库
│ │ ├─ Chunker.kt # 文档切片
│ │ ├─ Retriever.kt # 检索 + 组装
│ │ └─ RAGEngine.kt # 入口
├─ assets/
│ ├─ bge-small-zh.onnx # Embedding 模型
│ └─ gemma-1.5b-it-q4.task # LLM 模型
├─ docs/
│ └─ handbook.md # 离线知识库
七、性能与体验
首次启动:拷贝模型 + 切片入库 + 跑一次全量嵌入,耗时 10-60 秒
加载完后:检索 < 200ms,LLM 推理 < 2 秒(在 1.5B 模型上 50-100 token 回答)
内存峰值:1.5GB(LLM)+ 100MB(Embedding)+ DB 几十 MB
对话缓存:把历史 query 也存进向量库,让 RAG 也能用历史回答
八、合规要点
数据出端:向量数据库全部在本地,不向云端发送任何 chunk
权限控制:在 DB 中给每条文档打 tag("HR"、"财务"、"全员"),不同用户召回不同的 chunk
更新机制:定期从后端拉取最新文档,触发嵌入式更新;推送更新可走 P2P、长连接
用户隐私:用户的问题本身可以配置为"不入库"——避免污染后续检索
小结
端侧 RAG 是把"私人数据 + 通用 LLM"合体最干净的方案,工程量主要集中在 Embedding 和向量数据库两块。LLM 端选 1.5B 中文友好的(如 Qwen2.5-1.5B-Instruct-Q4)就行,Embedding 用 bge-small-zh。整个流程不需要服务器,App 端可以独立运行。
下一篇聊 Function Calling——让 LLM 主动调用 App 内部功能,把"问答"升级为"能干活"。
参考阅读
Hugging Face:RAG 综述博客
ONNX Runtime Mobile Android 文档
SQLite 向量检索实践(GSoC 2024 案例)