Administrator
发布于 2026-08-25 / 25 阅读
0
0

系列文章《Android 工程师的 100 篇进阶笔记》第 77 篇:RAG 在 Android 端的工程化:把公司文档喂给手机的正确姿势

前言

纯端侧 LLM 答不了"我们公司今年报销政策是啥"——因为模型知识停在训练截止日。RAG(Retrieval-Augmented Generation)通过"先检索本地知识库、再让 LLM 回答"的流程,把私有数据接入 LLM。这一篇拆解:如何在 Android 工程里搭一个能用的端侧 RAG。

一、RAG 在 Android 上的完整链路

┌──────────────┐    ┌────────────┐    ┌──────────────┐
│  用户问题     │───▶│ Embedding  │───▶│ 向量数据库   │
└──────────────┘    └────────────┘    └──────────────┘
                           │                 │
                           ▼                 ▼
                     ┌────────────┐    ┌────────────┐
                     │ Top-K 召回 │◀───│ 余弦相似度  │
                     └────────────┘    └────────────┘
                           │
                           ▼
                     ┌────────────┐    ┌────────────┐
                     │ Prompt 组装 │───▶│ LLM 推理   │
                     └────────────┘    └────────────┘
                                            │
                                            ▼
                                      ┌────────────┐
                                      │ 输出答案   │
                                      └────────────┘

整套流程在 Android 上跑得动,关键在于每个环节都选对模型。

二、端侧 Embedding 模型选择

2.1 选型标准

维度

推荐值

模型大小

< 100MB

输出维度

256-1024

多语言支持

中文必须支持

推理速度

< 50ms / 句

2.2 主流选择

模型

大小

维度

中文

速度

all-MiniLM-L6-v2

22MB

384

弱

20ms

bge-small-en-v1.5

33MB

384

弱

25ms

bge-small-zh-v1.5

33MB

512

强

28ms

multilingual-e5-small

100MB

384

中

40ms

nomic-embed-text-v1.5

50MB

768

中

35ms

中文场景推荐 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 三种主流方案

方案

适合规模

持久化

实现难度

内存 Map

< 10K 条

否

极简

Hnswlib

10K - 1M

文件

中

SQLite + 扩展

1M+

DB

中

FAISS(Mobile)

100K+

文件

高

中等 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+ 条数据再考虑。

四、文档切片:分块策略决定召回质量

最容易被忽视的一步。常见策略:

策略

适用

chunk 大小

固定长度

通用

200-300 字

段落级

结构化文档

1-5 段

滑动窗口

长文本

200 字 + 50 重叠

语义切片

Q&A

按 Q&A 对切片

代码示例:

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 案例)


评论