RAG:给大模型装一个靠谱的「本地数据库」——Android工程师秒懂的检索增强生成

0 阅读11分钟

系列开篇:为什么Android老兵该学AI开发?

说个真事。上个月团队技术周会,后端同学演示了一个内部知识问答系统——用RAG+Agent搭的,能对着我们几百页的业务文档回答问题,还能自动查Jira拉关联需求。当时我的第一反应是:这活儿也不难啊,不就是「数据检索→拼到Prompt里→调API」?

然后我花了一个周末试着自己搭了一个。结果发现——嗯,核心逻辑确实不难,但里面的工程细节和Android开发的相似度高得离谱。什么分层架构、缓存策略、数据源管理、异步调度……全是老朋友换了个马甲。

这就是我决定写这个系列的原因。不是教你从零学Python(你肯定会),而是用Android工程师的「母语」来翻译AI开发的核心概念,让你发现:你其实已经具备了80%的思维模型,缺的只是那20%的领域知识

这个系列会覆盖: ① RAG(检索增强生成)—— 本篇 ② Agent智能体 —— 工具调用与任务编排 ③ 微调 —— LoRA/QLoRA实战 ④ 组合拳 —— 三者融合搭建完整AI助手 每篇都从Android的类比切入,带实战代码,保证你能跑起来。

大模型的「幻觉」问题:RecyclerView没绑数据源

你肯定遇到过这种bug:RecyclerView显示出来了,item布局也渲染了,但里面的数据全是错的——要么是空的,要么是上一次的残留。原因很简单:你忘了绑定正确的数据源。

大模型的「幻觉」问题本质上是同一件事。GPT/Claude这些模型训练数据截止到某个时间点,之后的事它不知道。你问它你们公司的内部业务逻辑,它会非常自信地瞎编——就像那个没绑数据源的RecyclerView,布局很漂亮,内容全靠蒙。

RAG(Retrieval-Augmented Generation,检索增强生成)就是解决这个问题的。核心思想极其朴素:别让模型凭记忆回答,先帮它找到相关资料,贴在问题前面一起交给它

听起来是不是特别像你在Android里干的事?用户请求数据→先查本地缓存/数据库→拼装到UI层展示。一模一样的模式。

用ContentProvider的思维理解RAG架构

让我用一个你绝对熟悉的Android概念来翻译RAG的架构:

RAG 概念Android 类比干什么的
外部知识库ContentProvider存储可被检索的数据
Embedding向量化建索引(index)把数据变成可快速查询的格式
Retrieval检索query()根据条件找到相关数据
Prompt注入Cursor → 数据映射把检索结果喂给消费方
Generation生成Adapter.bind()基于数据生成最终输出

如果你把RAG系统看成一个ContentProvider的使用流程,整个架构瞬间就清晰了:

原始文档(你的知识库)

↓ 切片 + 向量化

向量数据库(你的ContentProvider)

↓ 用户提问触发 query()

检索Top-K相关文档片段

↓ 拼入Prompt(相当于bind数据)

LLM生成最终回答

RAG核心流程拆解

让我把RAG的完整流程拆开,每一步都对应到你熟悉的概念:

1. 文档切片(Chunking)—— 相当于DiffUtil拆分数据

你不会把一整个List一次性丢给RecyclerView吧?你会分页、会用DiffUtil做增量更新。RAG也一样——你不能把一份200页的PDF整个塞进Prompt(token限制),得先切成小块。

切片策略直接影响检索质量,就像分页策略影响列表加载体验。切太大——检索精度低(信噪比差);切太小——上下文丢失(用户看到半句话)。

# 文档切片 —— 类似分页逻辑
from langchain.text_splitter import (
    RecursiveCharacterTextSplitter
)

splitter = RecursiveCharacterTextSplitter(
    chunk_size=500,
    # 重叠部分,防止切断语义
    chunk_overlap=50,
    separators=[
        "\n\n",
        "\n",
        "。",
        ".",
        " "
    ]
)

# 加载Android官方文档
docs = load_documents(
    "android_docs/"
)
chunks = splitter.split_documents(docs)
print(
    f"切成 {len(chunks)} 块"
)

那个 chunk_overlap=50 很关键——相当于RecyclerView的prefetch,让相邻块之间有重叠,避免关键信息恰好被切断在边界上。

2. 向量化(Embedding)—— 相当于给数据建索引

Room数据库为什么要加 @Index?因为你得让查询变快。Embedding做的事类似——把文本转成一个高维向量(通常是768或1536维),这样就能用「向量相似度」来做语义层面的快速检索。

关键区别是:传统索引做的是精确匹配(where id = 123),向量索引做的是语义相似度匹配("怎么做网络请求"能匹配到"Retrofit的使用方法")。这是AI检索比传统全文检索强的地方。

# Embedding —— 给文本建向量索引
from langchain_openai import (
    OpenAIEmbeddings
)
from langchain_community.vectorstores import (
    Chroma
)

# 选择Embedding模型
# 相当于选图片加载库
embeddings = OpenAIEmbeddings(
    model="text-embedding-3-small"
)

# 存入向量数据库
# 相当于Room.databaseBuilder()
vectorstore = Chroma.from_documents(
    documents=chunks,
    embedding=embeddings,
    persist_directory="./chroma_db"
)

3. 检索 + 生成 —— query() + bind()

用户提了一个问题,先去向量库里找最相关的K个片段(默认通常是4个),然后把这些片段和问题一起拼成Prompt交给大模型:

# 完整RAG链 = query + bind + 生成
from langchain.chains import (
    RetrievalQA
)
from langchain_openai import (
    ChatOpenAI
)

llm = ChatOpenAI(
    model="gpt-4o",
    temperature=0
)

# 构建检索器(= 配置query参数)
retriever = vectorstore.as_retriever(
    search_kwargs={"k": 4}
)

# 组装RAG链
qa_chain = RetrievalQA.from_chain_type(
    llm=llm,
    retriever=retriever,
    return_source_documents=True
)

# 提问
result = qa_chain.invoke(
    "Jetpack Compose的recomposition"
    "触发条件是什么?"
)
print(result["result"])

就这么几行代码,一个能回答Android官方文档问题的RAG系统就跑起来了。是不是比想象中简单?核心就三步:切片→向量化存储→检索+生成。

向量数据库选型:SQLite vs Room vs Realm的AI版

做Android开发选数据库,你会根据场景选SQLite直接上、用Room封装、或者试试Realm。向量数据库的选型逻辑一模一样:

向量DB类比适用场景
ChromaSQLite本地开发/原型验证,零配置即可用
PineconeFirebase Realtime DB云托管,免运维,按量付费
Milvus自建MySQL集群大规模生产环境,需要自己运维
WeaviateRoom(带ORM)内置语义搜索+混合检索,开箱即用

我的建议:先用Chroma跑通原型(5分钟上手),验证效果后再决定要不要迁移到Pinecone或Milvus。这和你开发App时先用内存数据验证逻辑、再接Room的思路一样。别在原型阶段就搞分布式向量集群,那是过早优化。

Embedding模型选择:Glide vs Coil的AI版

选Embedding模型和选图片加载库一样纠结。列几个主流选择给你参考:

模型维度特点
OpenAI text-embedding-3-small1536最方便,效果好,要花钱
BGE-large-zh1024中文最强开源,可本地部署
Jina Embeddings v31024多语言均衡,长文本支持好

如果你的文档主要是中文,BGE是当前性价比最高的选择——开源免费,效果在MTEB榜单上打得过大部分商用模型。类比的话,就像Coil在Kotlin项目里的地位:轻量、现代、够用。

如果图省事不想管部署,OpenAI的embedding API调用成本极低(100万token约0.02美元),相当于Glide——啥也不用想,直接用就完事了。

实战:搭建Android文档RAG问答系统

说了这么多概念,来个完整可跑的例子。目标:把Android官方文档的某个章节做成RAG,能用自然语言问答。

# pip install langchain langchain-openai
#   chromadb tiktoken

import os
from langchain_community.document_loaders import (
    DirectoryLoader,
    TextLoader
)
from langchain.text_splitter import (
    RecursiveCharacterTextSplitter
)
from langchain_openai import (
    OpenAIEmbeddings,
    ChatOpenAI
)
from langchain_community.vectorstores import (
    Chroma
)
from langchain.chains import (
    RetrievalQA
)
from langchain.prompts import (
    PromptTemplate
)

# Step 1: 加载文档
loader = DirectoryLoader(
    "./android_docs",
    glob="**/*.md",
    loader_cls=TextLoader
)
docs = loader.load()

# Step 2: 切片
splitter = RecursiveCharacterTextSplitter(
    chunk_size=500,
    chunk_overlap=50
)
chunks = splitter.split_documents(docs)

# Step 3: 向量化并存入Chroma
embeddings = OpenAIEmbeddings(
    model="text-embedding-3-small"
)
db = Chroma.from_documents(
    chunks,
    embeddings,
    persist_directory="./chroma_android"
)

# Step 4: 构建RAG问答链
PROMPT = PromptTemplate(
    template="""基于以下Android文档内容
回答问题。如果文档中没有相关
信息,请说明你不确定。

文档内容:
{context}

问题:{question}
回答:""",
    input_variables=[
        "context",
        "question"
    ]
)

qa = RetrievalQA.from_chain_type(
    llm=ChatOpenAI(
        model="gpt-4o",
        temperature=0
    ),
    retriever=db.as_retriever(
        search_kwargs={"k": 4}
    ),
    chain_type_kwargs={
        "prompt": PROMPT
    },
    return_source_documents=True
)

# 试一下
resp = qa.invoke(
    "ViewModel在配置变更时"
    "如何保持数据?"
)
print(resp["result"])
print("---来源---")
for doc in resp["source_documents"]:
    print(
        doc.metadata["source"][:40]
    )

整个代码50行左右,核心逻辑清晰得就像一个标准的Android Repository模式:DataSource→Repository→ViewModel→UI。

RAG调优三板斧

跑是跑起来了,但你会发现——检索质量时好时坏。有时候问个简单问题能检索到完美的文档片段,有时候明明文档里有答案却死活找不到。

这就像RecyclerView列表滑动卡顿——问题往往不在UI层,而在数据层。RAG的「卡顿」(回答质量差)也一样,99%的问题出在检索环节,不是大模型不行。

板斧一:优化Chunk策略

默认的固定长度切片太粗暴了。更好的做法是按语义边界切——标题、段落、代码块各自成chunk,保持语义完整性。

# Markdown专用切片器
from langchain.text_splitter import (
    MarkdownHeaderTextSplitter
)

headers = [
    ("#", "h1"),
    ("##", "h2"),
    ("###", "h3"),
]

md_splitter = MarkdownHeaderTextSplitter(
    headers_to_split_on=headers
)
# 每个chunk保留层级上下文
# 类似Fragment知道自己在
# 哪个Activity下

板斧二:混合检索(Hybrid Search)

纯向量检索有个致命弱点:对专有名词和精确匹配不敏感。你搜"ViewModelScope",语义检索可能给你返回"协程作用域管理"相关的内容——语义是对的,但不是你要的那个具体类。

解决方案:混合检索 = 向量检索(语义) + BM25/关键词检索(精确)。两路结果合并排序,取长补短。

# 混合检索 = 向量 + 关键词
from langchain.retrievers import (
    EnsembleRetriever
)
from langchain_community.retrievers import (
    BM25Retriever
)

# 关键词检索器
bm25 = BM25Retriever.from_documents(
    chunks
)
bm25.k = 4

# 向量检索器
vector_retriever = db.as_retriever(
    search_kwargs={"k": 4}
)

# 融合:各占50%权重
ensemble = EnsembleRetriever(
    retrievers=[
        bm25,
        vector_retriever
    ],
    weights=[0.5, 0.5]
)

这就像Android的搜索功能同时走本地SQLite FTS全文索引和远程搜索API,然后合并结果——各有擅长,组合最强。

板斧三:Reranker重排序

检索回来的Top-K结果排序不一定准确。Reranker是一个轻量的交叉注意力模型,专门做「这个文档片段和用户问题到底有多相关」的精细打分。

类比的话——初次检索相当于RecyclerView的粗略布局(onMeasure),Reranker相当于精确布局(onLayout)。先粗筛,再精排。

# Reranker重排序
# pip install flashrank
from langchain.retrievers import (
    ContextualCompressionRetriever
)
from langchain_community.document_compressors import (
    FlashrankRerank
)

# 先检索20个候选
base_retriever = db.as_retriever(
    search_kwargs={"k": 20}
)

# Reranker精排到Top-4
compressor = FlashrankRerank(
    top_n=4
)
rerank_retriever = (
    ContextualCompressionRetriever(
        base_compressor=compressor,
        base_retriever=base_retriever
    )
)

常见踩坑:那些让你白debug半天的问题

做了两个月RAG项目,分享几个血泪教训:

坑1:chunk太大导致检索命中但答案被淹没

chunk设成2000字符,检索确实命中了相关文档,但那个关键信息只占其中一小段。大模型面对一大堆上下文,反而找不到重点。解决:chunk_size控制在300-600,宁可多检索几个chunk。

坑2:Embedding模型和查询语言不匹配

用英文Embedding模型存中文文档,检索质量断崖下跌。就像在英文键盘上打中文——能用,但体验极差。中文文档一定要用中文优化过的Embedding模型(BGE-zh/M3E)。

坑3:没做Query改写

用户问"怎么让列表不卡",你的文档里写的是"RecyclerView性能优化"。直接拿用户原话去做向量检索,可能匹配不上。做一步Query Rewrite(让LLM先把用户问题改写成更精确的检索query),召回率能提升30%+。

坑4:忽略了metadata过滤

文档有版本、有时间。用户问Compose的问题,你把Compose 1.0和最新版的文档混在一起返回——信息冲突,大模型也懵了。给chunk打上metadata标签(版本号、更新时间、所属模块),检索时加filter。

一句话总结RAG调优思路:检索质量差≈列表滑动卡顿。问题99%出在数据层(切片策略/Embedding质量/检索逻辑),不是出在LLM生成层。先优化「数据供给」,再考虑换更贵的模型。

写在最后:你的Android经验比你想象的值钱

回顾一下今天的内容——RAG的核心就是「先检索,再生成」。而这种「数据驱动UI」的思维模式,Android工程师每天都在用。你做过的Repository模式、做过的缓存策略、做过的搜索功能优化,全都能直接迁移到RAG系统的设计中。

AI开发不是另一个世界。它是你已有能力的延伸,不是推倒重来。

下一篇,我们聊Agent智能体——让AI不只是回答问题,还能自己调API干活。如果说RAG是"给AI装了个本地数据库",那Agent就是"给AI装了一套Intent系统"。到时候你会发现,Android的Service/BroadcastReceiver/WorkManager这些老朋友,又要派上用场了。

—— 觉得有用?转发给你身边想跨界AI的Android同行 ——