RAG 技术原理与知识库实践

作者整理日期:2026-06-09 技术栈:Python 3.11 / LangChain / LlamaIndex / Chroma / Qdrant / Ollama


1. RAG 基础概念

1.1 什么是 RAG

RAG(Retrieval-Augmented Generation,检索增强生成)是一种将外部知识检索与大型语言模型生成能力相结合的技术范式。它由 Meta AI Research 在 2020 年的论文《Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks》中正式提出。

核心思想:在生成答案之前,先从外部知识库中检索最相关的文档片段,将其作为上下文注入 Prompt,从而让 LLM 基于真实、最新、私有的知识进行回答。

用户问题 → [检索引擎] → 相关文档片段 → [LLM] → 基于文档的回答

1.2 为什么需要 RAG:LLM 的四大局限

局限一:知识截止日期(Knowledge Cutoff)

每个 LLM 都有训练数据截止日期。GPT-4 的截止日期是 2024 年初,Claude 3.5 是 2024 年中。任何截止日期之后发生的事件,模型都无法知晓。这对于需要实时信息的场景(技术文档、产品规格、法律条规)来说是致命缺陷。

局限二:私有数据无法访问

企业内部文档、个人笔记、代码仓库、数据库——这些从未出现在公开互联网上的数据,LLM 自然不知道。RAG 正是解决这个问题最主流的方案。

局限三:幻觉问题(Hallucination)

LLM 本质上是概率语言模型,它”倾向于生成听起来合理的内容”,而不是”只说自己知道的”。当模型对某个问题没有足够训练数据时,它会自信地编造答案——这就是幻觉。RAG 通过提供真实的检索证据,能显著抑制幻觉的发生。

局限四:Context Window 限制

GPT-4 Turbo 有 128k token 的上下文窗口,Gemini 1.5 Pro 有 1M token。但真实的企业知识库可能有几百万份文档、几十亿 token。RAG 通过精准检索,只把最相关的几千 token 送入模型,解决了”装不下”的问题。

1.3 RAG vs Fine-tuning vs In-context Learning

维度Fine-tuning(微调)In-context LearningRAG
知识更新成本高(需重新训练)中(需重写 Prompt)低(更新数据库即可)
私有数据支持需要大量标注数据受 context 窗口限制天然支持,无大小限制
推理延迟中(需要检索步骤)
幻觉抑制高(有文档证据)
可解释性高(可引用来源)
适用场景改变模型风格/能力少量示例引导知识密集型问答

结论:三者不是互斥关系,而是互补的。最佳实践是:先用 Fine-tuning 让模型掌握领域语言风格,再用 RAG 提供实时知识,再用 In-context Learning 提供格式示例。

1.4 RAG 的三个核心阶段

阶段一:索引(Indexing) — 离线预处理

原始文档 → 加载解析 → 文本分块(Chunking)→ 向量化(Embedding)→ 存入向量数据库

阶段二:检索(Retrieval) — 在线查询

用户问题 → 向量化 → 向量相似度搜索 → Top-K 文档片段

阶段三:生成(Generation) — LLM 推理

[系统 Prompt] + [检索到的文档] + [用户问题] → LLM → 最终回答

2. 向量嵌入原理

2.1 Embedding 是什么:语义的数学表达

Embedding(嵌入)是将高维离散的文本信息映射到低维连续向量空间的过程。直觉上:语义相似的文本,其向量在空间中距离更近

例如,以下三句话的向量关系:

  • “苹果是一种水果” → [0.82, -0.31, 0.54, ...](1536 维)
  • “Apple is a fruit” → [0.81, -0.29, 0.52, ...](1536 维) ← 语义接近
  • “特斯拉是一家汽车公司” → [-0.41, 0.67, -0.23, ...](1536 维) ← 语义远离

前两句的余弦相似度可能高达 0.97,而与第三句只有 0.12。

现代 Embedding 模型的工作原理是 Transformer 编码器(BERT 架构),通过大规模对比学习(Contrastive Learning)训练,让语义相近的句子对的向量靠近,语义不同的句子对的向量远离。

2.2 嵌入模型选型

OpenAI text-embedding-3-large

  • 维度:3072(可压缩到 256)
  • 价格:$0.13/1M tokens
  • 质量:MTEB 排行榜前列,多语言支持优秀
  • 缺点:需要联网,有隐私风险,按量付费
from openai import OpenAI
 
client = OpenAI()
response = client.embeddings.create(
    model="text-embedding-3-large",
    input="你好,世界",
    dimensions=1024  # 可压缩以节省存储
)
vector = response.data[0].embedding  # List[float], 长度 1024

BGE-M3(中英文最佳开源模型)

由智源研究院(BAAI)推出,目前中文 RAG 场景的首选开源模型。

  • 维度:1024
  • 特点:支持中英文双语、支持多粒度检索(稠密+稀疏+多向量)
  • 上下文长度:最大 8192 tokens
  • 模型大小:约 2.3GB
  • MTEB 中文榜:持续排名 Top 3
from FlagEmbedding import BGEM3FlagModel
 
model = BGEM3FlagModel('BAAI/bge-m3', use_fp16=True)
 
# 批量编码
sentences = ["什么是 RAG?", "RAG 全称检索增强生成"]
embeddings = model.encode(sentences, batch_size=12, max_length=512)['dense_vecs']
# embeddings.shape: (2, 1024)

nomic-embed-text(本地轻量)

  • 维度:768
  • 特点:完全开源(Apache 2.0),可本地运行,速度极快
  • 部署:通过 Ollama 一行命令即可使用
ollama pull nomic-embed-text
import ollama
 
response = ollama.embeddings(
    model='nomic-embed-text',
    prompt='这是一段需要嵌入的文本'
)
vector = response['embedding']  # List[float], 长度 768

维度、速度、质量三角权衡

模型维度速度(CPU)中文质量隐私成本
text-embedding-3-large3072云端极好付费
BGE-M31024极好免费
BGE-small-zh512免费
nomic-embed-text768免费
all-MiniLM-L6-v2384极快免费

选型建议

  • 个人知识库(中文为主)→ BGE-M3BGE-small-zh
  • 隐私要求极高 + 资源受限 → nomic-embed-text(Ollama)
  • 商业产品 + 预算充足 → text-embedding-3-large

2.3 余弦相似度 vs 欧氏距离

余弦相似度(Cosine Similarity)

cos(A, B) = (A · B) / (||A|| × ||B||)

范围 [-1, 1],值越大越相似。不受向量长度影响,只关注方向。这是文本检索最常用的度量,因为文本的语义信息主要编码在方向上而非长度上。

欧氏距离(L2 Distance)

d(A, B) = sqrt(Σ(Aᵢ - Bᵢ)²)

值越小越相似。对向量的绝对值敏感,适用于图像检索等场景。

对于归一化后的向量(单位向量),余弦相似度和 L2 距离是等价的(cos_sim = 1 - L2²/2),大多数 Embedding 模型输出的向量已经归一化,因此两种度量的检索结果相同。

import numpy as np
from sklearn.metrics.pairwise import cosine_similarity
 
# 余弦相似度
def cos_sim(a: list, b: list) -> float:
    a, b = np.array(a), np.array(b)
    return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)))
 
# 实际使用中向量已归一化,可直接点积
def cos_sim_normalized(a: np.ndarray, b: np.ndarray) -> float:
    return float(np.dot(a, b))

2.4 Chunking(文本分块)策略

分块策略是 RAG 质量的关键,直接影响检索精度。

策略一:固定大小分块(Fixed-size Chunking)

from langchain.text_splitter import RecursiveCharacterTextSplitter
 
splitter = RecursiveCharacterTextSplitter(
    chunk_size=512,      # 每块最大字符数
    chunk_overlap=64,    # 相邻块的重叠字符数,避免语义在边界截断
    separators=["\n\n", "\n", "。", "!", "?", " ", ""]
)
chunks = splitter.split_text(document_text)

优点:简单,速度快。缺点:可能在句子中间截断,损失语义完整性。

策略二:语义边界分块(Semantic Chunking)

from langchain_experimental.text_splitter import SemanticChunker
from langchain_community.embeddings import HuggingFaceEmbeddings
 
embedder = HuggingFaceEmbeddings(model_name="BAAI/bge-small-zh-v1.5")
splitter = SemanticChunker(
    embedder,
    breakpoint_threshold_type="percentile",  # 用嵌入相似度变化检测语义边界
    breakpoint_threshold_amount=95           # 第 95 百分位的相似度差异处分割
)
chunks = splitter.create_documents([document_text])

优点:块内语义高度一致。缺点:计算开销大(需要对每个句子嵌入)。

策略三:Markdown 感知分块

from langchain.text_splitter import MarkdownHeaderTextSplitter
 
headers_to_split_on = [
    ("#", "H1"),
    ("##", "H2"),
    ("###", "H3"),
]
splitter = MarkdownHeaderTextSplitter(headers_to_split_on=headers_to_split_on)
md_chunks = splitter.split_text(markdown_text)
# 每个 chunk 自动携带 {'H1': '章节名', 'H2': '子节名'} 元数据

最佳实践分块参数(针对中文技术文档):

chunk_size = 400~600 字符
chunk_overlap = 50~100 字符(约 10~15%)

3. 向量数据库选型

3.1 主流向量数据库详细对比

数据库语言许可证本地/云持久化全文搜索元数据过滤推荐场景
ChromaPython/GoApache 2.0本地+云原型/个人项目
QdrantRustApache 2.0本地+云是(强)生产环境
WeaviateGoBSD 3本地+云混合检索
FAISSC++MIT本地需手动纯向量搜索研究
LanceDBRustApache 2.0本地是(Arrow)多模态/数据分析
MilvusGo/C++Apache 2.0本地+云大规模企业
pgvectorCPostgreSQL本地+云是(PG)是(SQL)已有 PostgreSQL

3.2 选型建议

个人知识库 / 快速原型 → Chroma

零配置,纯 Python,持久化只需指定一个目录。适合 10 万以下的文档块。

生产环境 / 高并发 → Qdrant

Rust 编写,内存效率高,支持命名过滤(payload filter),支持多向量、稀疏向量。Docker 一行部署,REST + gRPC 双接口。

已有 PostgreSQL → pgvector

无需新增基础设施,SQL 语法直接操作向量。支持 IVFFlat 和 HNSW 索引。

多模态数据(图像+文本)→ LanceDB

原生 Apache Arrow 格式,与 Pandas/Polars 完美集成,支持图像向量。


4. 知识库 RAG Pipeline 完整实现

以下是一个完整、可运行的 RAG Pipeline,使用 LangChain + Chroma + BGE-M3(本地)或 OpenAI(云端)。

4.1 环境准备

pip install langchain langchain-community langchain-chroma \
            chromadb FlagEmbedding sentence-transformers \
            python-frontmatter tiktoken

4.2 核心 Pipeline 代码

"""
rag_pipeline.py — 完整 RAG Pipeline
支持:Obsidian Markdown 加载 / BGE-M3 嵌入 / Chroma 存储 / 检索 / LLM 生成
"""
import os
import hashlib
from pathlib import Path
from typing import Optional
 
import chromadb
from chromadb.config import Settings
import frontmatter  # python-frontmatter
from FlagEmbedding import BGEM3FlagModel
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_core.documents import Document
 
 
# ─────────────────────────────────────────────
# 1. 嵌入模型封装
# ─────────────────────────────────────────────
class BGEEmbedder:
    """本地 BGE-M3 嵌入模型,支持批量编码"""
 
    def __init__(self, model_name: str = "BAAI/bge-m3", use_fp16: bool = True):
        print(f"加载嵌入模型: {model_name} ...")
        self.model = BGEM3FlagModel(model_name, use_fp16=use_fp16)
 
    def embed_texts(self, texts: list[str], batch_size: int = 16) -> list[list[float]]:
        """批量将文本列表转为向量列表"""
        result = self.model.encode(
            texts,
            batch_size=batch_size,
            max_length=512,
            return_dense=True,
            return_sparse=False,
            return_colbert_vecs=False
        )
        return result['dense_vecs'].tolist()
 
    def embed_query(self, query: str) -> list[float]:
        """将单个查询文本转为向量(查询时使用指令前缀)"""
        instructed = f"Represent this sentence for searching relevant passages: {query}"
        return self.embed_texts([instructed])[0]
 
 
# ─────────────────────────────────────────────
# 2. 文档加载器(Obsidian Markdown)
# ─────────────────────────────────────────────
class ObsidianLoader:
    """加载 Obsidian Vault 中的所有 Markdown 文件,解析 frontmatter 元数据"""
 
    def __init__(self, vault_path: str):
        self.vault_path = Path(vault_path)
        self.splitter = RecursiveCharacterTextSplitter(
            chunk_size=500,
            chunk_overlap=60,
            separators=["\n\n", "\n", "。", "!", "?", ";", " ", ""]
        )
 
    def _expand_wiki_links(self, text: str) -> str:
        """将 [[页面名]] 格式展开为 '页面名' 文本(简化处理)"""
        import re
        # [[Page|Alias]] → Alias;[[Page]] → Page
        text = re.sub(r'\[\[([^\]|]+)\|([^\]]+)\]\]', r'\2', text)
        text = re.sub(r'\[\[([^\]]+)\]\]', r'\1', text)
        return text
 
    def _file_hash(self, filepath: Path) -> str:
        """计算文件内容 MD5,用于增量更新判断"""
        return hashlib.md5(filepath.read_bytes()).hexdigest()
 
    def load_documents(self, exclude_dirs: list[str] | None = None) -> list[Document]:
        """加载所有 .md 文件并分块,返回 LangChain Document 列表"""
        exclude_dirs = set(exclude_dirs or [".obsidian", ".trash", "templates"])
        documents = []
 
        for md_file in sorted(self.vault_path.rglob("*.md")):
            # 跳过排除目录
            parts = set(md_file.relative_to(self.vault_path).parts)
            if parts & exclude_dirs:
                continue
 
            try:
                post = frontmatter.load(md_file)
            except Exception as e:
                print(f"跳过 {md_file}: {e}")
                continue
 
            raw_content = self._expand_wiki_links(post.content)
            if len(raw_content.strip()) < 50:  # 过滤空文件
                continue
 
            # 提取 frontmatter 元数据
            metadata_base = {
                "source": str(md_file.relative_to(self.vault_path)),
                "file_name": md_file.stem,
                "file_hash": self._file_hash(md_file),
                "tags": ",".join(post.metadata.get("tags", [])),
                "created": str(post.metadata.get("created", "")),
                "confidence": str(post.metadata.get("confidence", "medium")),
            }
 
            # 分块
            chunks = self.splitter.create_documents(
                texts=[raw_content],
                metadatas=[metadata_base]
            )
 
            # 为每块添加序号
            for i, chunk in enumerate(chunks):
                chunk.metadata["chunk_index"] = i
                chunk.metadata["total_chunks"] = len(chunks)
 
            documents.extend(chunks)
 
        print(f"加载完成:{len(documents)} 个文档块")
        return documents
 
 
# ─────────────────────────────────────────────
# 3. 向量存储(Chroma)
# ─────────────────────────────────────────────
class VectorStore:
    """基于 Chroma 的向量存储,支持增量更新"""
 
    def __init__(
        self,
        persist_dir: str,
        embedder: BGEEmbedder,
        collection_name: str = "knowledge_base"
    ):
        self.embedder = embedder
        self.client = chromadb.PersistentClient(
            path=persist_dir,
            settings=Settings(anonymized_telemetry=False)
        )
        self.collection = self.client.get_or_create_collection(
            name=collection_name,
            metadata={"hnsw:space": "cosine"}  # 使用余弦相似度
        )
        print(f"向量库已就绪,当前文档数: {self.collection.count()}")
 
    def add_documents(self, documents: list[Document], batch_size: int = 64):
        """批量添加文档,自动跳过已存在的(通过 file_hash + chunk_index 去重)"""
        new_docs = []
        for doc in documents:
            doc_id = f"{doc.metadata['file_hash']}_{doc.metadata['chunk_index']}"
            doc.metadata["_id"] = doc_id
            new_docs.append(doc)
 
        # 分批处理
        for i in range(0, len(new_docs), batch_size):
            batch = new_docs[i:i + batch_size]
            texts = [d.page_content for d in batch]
            ids = [d.metadata["_id"] for d in batch]
            metadatas = [d.metadata for d in batch]
 
            # 批量向量化
            embeddings = self.embedder.embed_texts(texts)
 
            # 使用 upsert(存在则更新,不存在则插入)
            self.collection.upsert(
                ids=ids,
                embeddings=embeddings,
                documents=texts,
                metadatas=metadatas
            )
            print(f"  已处理 {min(i + batch_size, len(new_docs))}/{len(new_docs)} 块")
 
    def query(
        self,
        query: str,
        top_k: int = 5,
        filter_dict: dict | None = None
    ) -> list[dict]:
        """
        检索最相关的文档块
        filter_dict 示例: {"tags": {"$contains": "python"}}
        """
        query_vector = self.embedder.embed_query(query)
        results = self.collection.query(
            query_embeddings=[query_vector],
            n_results=top_k,
            where=filter_dict,
            include=["documents", "metadatas", "distances"]
        )
 
        hits = []
        for doc, meta, dist in zip(
            results["documents"][0],
            results["metadatas"][0],
            results["distances"][0]
        ):
            hits.append({
                "content": doc,
                "metadata": meta,
                "score": 1 - dist,  # 余弦距离转相似度
            })
        return hits
 
 
# ─────────────────────────────────────────────
# 4. RAG 问答引擎
# ─────────────────────────────────────────────
RAG_SYSTEM_PROMPT = """你是一个基于个人知识库的智能助手。
请严格根据以下检索到的知识片段回答用户问题。
 
规则:
1. 只使用提供的知识片段中的信息,不要使用外部知识
2. 如果知识片段中没有足够信息,请明确说"根据现有知识库,无法回答此问题"
3. 回答时标注来源文件,格式:(来源:[文件名])
4. 置信度低的知识片段(confidence: low)请在引用时注明"此信息置信度较低"
 
已检索的知识片段:
{context}
 
请回答用户问题。"""
 
 
class RAGEngine:
    """RAG 问答引擎,支持本地 Ollama 和 OpenAI"""
 
    def __init__(
        self,
        vector_store: VectorStore,
        llm_backend: str = "ollama",  # "ollama" 或 "openai"
        model_name: str = "qwen2.5:7b",
        top_k: int = 5
    ):
        self.store = vector_store
        self.top_k = top_k
        self.backend = llm_backend
 
        if llm_backend == "ollama":
            import ollama as ollama_client
            self._llm = ollama_client
            self._model = model_name
        elif llm_backend == "openai":
            from openai import OpenAI
            self._llm = OpenAI()
            self._model = model_name or "gpt-4o-mini"
 
    def _build_context(self, hits: list[dict]) -> str:
        """将检索结果格式化为上下文字符串"""
        lines = []
        for i, hit in enumerate(hits, 1):
            meta = hit["metadata"]
            confidence_note = " [置信度低]" if meta.get("confidence") == "low" else ""
            lines.append(
                f"[片段 {i}] 来源: {meta.get('file_name', '未知')}{confidence_note}\n"
                f"相关度: {hit['score']:.3f}\n"
                f"{hit['content']}\n"
            )
        return "\n---\n".join(lines)
 
    def ask(self, question: str, filter_dict: dict | None = None) -> dict:
        """执行 RAG 问答,返回答案和引用来源"""
        # 检索
        hits = self.store.query(question, top_k=self.top_k, filter_dict=filter_dict)
 
        if not hits or hits[0]["score"] < 0.4:
            return {
                "answer": "根据现有知识库,未找到与此问题相关的内容。",
                "sources": [],
                "retrieved_chunks": hits
            }
 
        context = self._build_context(hits)
        prompt = RAG_SYSTEM_PROMPT.format(context=context)
        messages = [
            {"role": "system", "content": prompt},
            {"role": "user", "content": question}
        ]
 
        # 调用 LLM
        if self.backend == "ollama":
            response = self._llm.chat(model=self._model, messages=messages)
            answer = response["message"]["content"]
        else:
            response = self._llm.chat.completions.create(
                model=self._model, messages=messages
            )
            answer = response.choices[0].message.content
 
        # 提取来源
        sources = list({h["metadata"].get("source", "") for h in hits})
 
        return {
            "answer": answer,
            "sources": sources,
            "retrieved_chunks": hits
        }
 
 
# ─────────────────────────────────────────────
# 5. 主程序入口
# ─────────────────────────────────────────────
if __name__ == "__main__":
    VAULT_PATH = "/path/to/your/obsidian/vault"
    PERSIST_DIR = "/path/to/chroma_db"
 
    # 初始化组件
    embedder = BGEEmbedder("BAAI/bge-m3")
    store = VectorStore(PERSIST_DIR, embedder)
 
    # 加载并索引文档(首次运行或需要更新时执行)
    loader = ObsidianLoader(VAULT_PATH)
    docs = loader.load_documents()
    store.add_documents(docs)
 
    # 初始化 RAG 引擎
    engine = RAGEngine(store, llm_backend="ollama", model_name="qwen2.5:7b")
 
    # 交互问答
    while True:
        q = input("\n请输入问题(q 退出): ").strip()
        if q.lower() == "q":
            break
        result = engine.ask(q)
        print(f"\n回答:\n{result['answer']}")
        print(f"\n来源文件:{', '.join(result['sources'])}")

5. 高级 RAG 技术

5.1 HyDE(Hypothetical Document Embeddings)

核心思想:与其用”问题”的向量去匹配”答案”的向量(两者语义空间不对齐),不如先让 LLM 生成一个假设性答案,再用假设答案的向量去检索。

class HyDERetriever:
    """HyDE:用假设文档提升检索质量"""
 
    HYDE_PROMPT = """请为以下问题生成一段假设性的详细回答。
这段回答将用于检索相关文档,因此请尽量包含该领域的专业术语。
不需要回答正确,只需生成语义相关的内容。
 
问题: {question}
假设回答:"""
 
    def __init__(self, engine: RAGEngine):
        self.engine = engine
 
    def generate_hypothesis(self, question: str) -> str:
        """用 LLM 生成假设文档"""
        if self.engine.backend == "ollama":
            resp = self.engine._llm.chat(
                model=self.engine._model,
                messages=[{"role": "user", "content": self.HYDE_PROMPT.format(question=question)}]
            )
            return resp["message"]["content"]
        # OpenAI 版本类似...
 
    def ask(self, question: str) -> dict:
        # 生成假设文档
        hypothesis = self.generate_hypothesis(question)
        # 用假设文档+原始问题混合向量检索
        h_vec = self.engine.store.embedder.embed_query(hypothesis)
        q_vec = self.engine.store.embedder.embed_query(question)
        # 取两个向量的平均(简单融合)
        import numpy as np
        mixed_vec = ((np.array(h_vec) + np.array(q_vec)) / 2).tolist()
 
        # 直接用混合向量检索
        results = self.engine.store.collection.query(
            query_embeddings=[mixed_vec],
            n_results=self.engine.top_k
        )
        # ... 后续同正常 RAG 流程

适用场景:问题和答案的表达方式差异较大时(如”什么是XX” → 知识库中存储的是描述性段落)。

5.2 Parent-Child Chunking(父子分块)

核心思想:用小块精确检索,用大块提供完整上下文。

from langchain.retrievers import ParentDocumentRetriever
from langchain.storage import InMemoryByteStore
from langchain_chroma import Chroma
 
# 子块:用于检索(小而精确)
child_splitter = RecursiveCharacterTextSplitter(chunk_size=200, chunk_overlap=20)
# 父块:用于送入 LLM(大而完整)
parent_splitter = RecursiveCharacterTextSplitter(chunk_size=800, chunk_overlap=100)
 
vectorstore = Chroma(embedding_function=embedder, persist_directory="./chroma_db")
docstore = InMemoryByteStore()  # 存储父块(可替换为 Redis)
 
retriever = ParentDocumentRetriever(
    vectorstore=vectorstore,
    docstore=docstore,
    child_splitter=child_splitter,
    parent_splitter=parent_splitter,
)
 
retriever.add_documents(raw_documents)
 
# 检索时:自动用小块找到相关位置,返回对应的大块
parent_docs = retriever.invoke("你的问题")

效果:检索精度不损失,同时 LLM 得到更完整的上下文。

5.3 Re-ranking(重排序)

向量检索的 Top-K 是基于近似最近邻算法的粗排结果,精度有上限。Cross-Encoder 重排器通过对(query, document)对进行精细的交叉注意力打分,显著提升精度。

from sentence_transformers import CrossEncoder
 
class Reranker:
    """使用 BGE Reranker 对检索结果重排序"""
 
    def __init__(self, model_name: str = "BAAI/bge-reranker-v2-m3"):
        self.model = CrossEncoder(model_name)
 
    def rerank(self, query: str, hits: list[dict], top_n: int = 3) -> list[dict]:
        """对检索结果重排,返回前 top_n 个"""
        pairs = [(query, hit["content"]) for hit in hits]
        scores = self.model.predict(pairs)
 
        # 按重排分数排序
        for hit, score in zip(hits, scores):
            hit["rerank_score"] = float(score)
 
        reranked = sorted(hits, key=lambda x: x["rerank_score"], reverse=True)
        return reranked[:top_n]
 
 
# 在 RAGEngine.ask() 中集成 Re-ranking
def ask_with_rerank(self, question: str, reranker: Reranker) -> dict:
    # 先宽泛检索 20 个候选
    candidates = self.store.query(question, top_k=20)
    # 再精排取 Top 3
    hits = reranker.rerank(question, candidates, top_n=3)
    context = self._build_context(hits)
    # ... 后续生成步骤不变

重排器推荐

  • BAAI/bge-reranker-v2-m3(中英双语,推荐)
  • cross-encoder/ms-marco-MiniLM-L-6-v2(英文,轻量)

5.4 Self-query Retrieval(自查询检索)

让 LLM 自动从问题中提取结构化过滤条件,结合向量检索和元数据过滤。

from langchain.retrievers.self_query.base import SelfQueryRetriever
from langchain.chains.query_constructor.base import AttributeInfo
from langchain_openai import ChatOpenAI
 
# 定义元数据字段的含义
metadata_field_info = [
    AttributeInfo(name="tags", description="文档标签,如 python, ai, productivity", type="string"),
    AttributeInfo(name="confidence", description="知识置信度: high/medium/low", type="string"),
    AttributeInfo(name="created", description="创建日期,YYYY-MM-DD 格式", type="string"),
]
 
retriever = SelfQueryRetriever.from_llm(
    llm=ChatOpenAI(model="gpt-4o-mini"),
    vectorstore=vectorstore,
    document_contents="个人知识库笔记",
    metadata_field_info=metadata_field_info,
    verbose=True
)
 
# 用户问:"2025年之后写的、关于 Python 的高置信度笔记有哪些?"
# LLM 自动生成过滤条件: {"tags": {"$contains": "python"}, "confidence": "high"}
docs = retriever.invoke("2025年之后写的关于 Python 的高置信度笔记")

6. Obsidian Vault → RAG 完整方案

6.1 增量更新策略

核心思路:维护一个”文件指纹表”(哈希值),只重新索引发生变化的文件。

import json
from pathlib import Path
 
 
class IncrementalIndexer:
    """增量索引管理器:只重新索引变更的文件"""
 
    HASH_DB_PATH = ".rag_index_hashes.json"
 
    def __init__(self, vault_path: str, store: VectorStore):
        self.vault_path = Path(vault_path)
        self.store = store
        self.hash_db_path = self.vault_path / self.HASH_DB_PATH
        self._hashes = self._load_hashes()
 
    def _load_hashes(self) -> dict:
        if self.hash_db_path.exists():
            return json.loads(self.hash_db_path.read_text())
        return {}
 
    def _save_hashes(self):
        self.hash_db_path.write_text(json.dumps(self._hashes, indent=2, ensure_ascii=False))
 
    def _file_hash(self, filepath: Path) -> str:
        return hashlib.md5(filepath.read_bytes()).hexdigest()
 
    def update(self, loader: ObsidianLoader):
        """扫描所有文件,只更新变更的部分"""
        all_md_files = list(self.vault_path.rglob("*.md"))
        changed_files, deleted_files = [], []
 
        # 检测变更和新增
        current_keys = set()
        for f in all_md_files:
            key = str(f.relative_to(self.vault_path))
            current_keys.add(key)
            new_hash = self._file_hash(f)
            if self._hashes.get(key) != new_hash:
                changed_files.append(f)
                self._hashes[key] = new_hash
 
        # 检测删除
        for key in list(self._hashes.keys()):
            if key not in current_keys:
                deleted_files.append(key)
                del self._hashes[key]
 
        # 删除旧索引
        if deleted_files:
            print(f"删除 {len(deleted_files)} 个已删除文件的索引...")
            for key in deleted_files:
                # 根据文件名前缀删除相关块
                results = self.store.collection.get(where={"source": key})
                if results["ids"]:
                    self.store.collection.delete(ids=results["ids"])
 
        # 重新索引变更文件
        if changed_files:
            print(f"重新索引 {len(changed_files)} 个变更文件...")
            # 先删除旧块
            for f in changed_files:
                key = str(f.relative_to(self.vault_path))
                results = self.store.collection.get(where={"source": key})
                if results["ids"]:
                    self.store.collection.delete(ids=results["ids"])
 
            # 加载并重新索引
            new_docs = []
            for f in changed_files:
                file_docs = loader.load_documents_from_file(f)
                new_docs.extend(file_docs)
 
            if new_docs:
                self.store.add_documents(new_docs)
 
        self._save_hashes()
        print(f"增量更新完成:变更 {len(changed_files)} 个文件,删除 {len(deleted_files)} 个文件")

7. 知识库问答系统设计

7.1 Prompt Engineering for RAG

一个高质量的 RAG Prompt 需要包含以下元素:

RAG_PROMPT_TEMPLATE = """你是一个基于个人知识库的智能助手。
 
## 你的知识来源
以下是从知识库中检索到的相关内容(按相关度排序):
 
{context}
 
## 回答规则
1. **严格依据上述内容回答**,不得使用知识库以外的信息
2. **引用来源**:每个关键陈述后注明来源,格式:`(来源:文件名)`
3. **置信度传递**:若某片段标注 [置信度低],相关陈述后注明"此信息置信度较低,建议验证"
4. **无法回答时**:若知识库内容不足以回答,明确说明"当前知识库中没有足够信息回答此问题",不得推测或捏造
5. **结构化输出**:对于复杂问题,使用标题和列表组织回答
 
## 用户问题
{question}
 
## 你的回答
"""

7.2 置信度传递机制

Obsidian 笔记的 frontmatter 中可以标注置信度:

---
title: Python GIL 机制
tags: [python, concurrency]
confidence: high   # high / medium / low
source: "Python 官方文档 3.12"
created: 2025-03-15
---

RAG 系统在回答时将置信度传递给用户:

def format_hit_with_confidence(hit: dict) -> str:
    meta = hit["metadata"]
    confidence = meta.get("confidence", "medium")
    confidence_label = {
        "high": "",
        "medium": " ⚠️ 中等置信度",
        "low": " ❗ 低置信度,建议验证"
    }.get(confidence, "")
 
    return (
        f"来源:{meta['file_name']}{confidence_label}\n"
        f"相关度:{hit['score']:.2f}\n"
        f"内容:{hit['content']}"
    )

7.3 无答案处理

def ask_with_guardrails(self, question: str) -> dict:
    hits = self.store.query(question, top_k=5)
 
    # 相关度过低:直接拒绝
    max_score = max((h["score"] for h in hits), default=0)
    if max_score < 0.45:
        return {
            "answer": f"当前知识库中没有与「{question}」相关的内容。\n"
                      f"最高相关度仅 {max_score:.2f}(阈值 0.45),建议:\n"
                      f"1. 换用其他关键词重试\n"
                      f"2. 在知识库中添加相关笔记",
            "sources": [],
            "refused": True
        }
 
    # 正常问答流程...

8. 本地部署方案(隐私优先)

8.1 完整本地技术栈

Ollama(本地 LLM + 嵌入)
    ├── LLM: qwen2.5:7b 或 llama3.1:8b
    └── Embedding: nomic-embed-text 或 BGE-M3(通过 HuggingFace 本地加载)

Chroma(本地向量数据库)
    └── 持久化到本地目录

Python RAG Application
    └── LangChain / 自定义 Pipeline

8.2 Docker Compose 一键部署

# docker-compose.yml
version: "3.9"
 
services:
  ollama:
    image: ollama/ollama:latest
    container_name: rag_ollama
    ports:
      - "11434:11434"
    volumes:
      - ollama_data:/root/.ollama
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: all
              capabilities: [gpu]
    restart: unless-stopped
 
  chromadb:
    image: chromadb/chroma:latest
    container_name: rag_chroma
    ports:
      - "8000:8000"
    volumes:
      - chroma_data:/chroma/chroma
    environment:
      - CHROMA_SERVER_AUTH_PROVIDER=chromadb.auth.token_authn.TokenAuthenticationServerProvider
      - CHROMA_SERVER_AUTH_CREDENTIALS=your-secret-token
      - ANONYMIZED_TELEMETRY=False
    restart: unless-stopped
 
  rag_app:
    build:
      context: .
      dockerfile: Dockerfile
    container_name: rag_app
    ports:
      - "7860:7860"  # Gradio UI
    volumes:
      - ./vault:/vault:ro       # Obsidian Vault(只读挂载)
      - ./index:/app/index      # 索引缓存
    environment:
      - OLLAMA_BASE_URL=http://ollama:11434
      - CHROMA_HOST=chromadb
      - CHROMA_PORT=8000
      - CHROMA_TOKEN=your-secret-token
      - VAULT_PATH=/vault
    depends_on:
      - ollama
      - chromadb
    restart: unless-stopped
 
volumes:
  ollama_data:
  chroma_data:
# Dockerfile
FROM python:3.11-slim
 
WORKDIR /app
 
RUN pip install --no-cache-dir \
    langchain langchain-community langchain-chroma \
    chromadb FlagEmbedding sentence-transformers \
    python-frontmatter gradio
 
COPY . .
 
# 拉取 Ollama 模型(首次启动时执行)
CMD ["python", "app.py"]

一键启动:

docker compose up -d
 
# 拉取需要的模型
docker exec rag_ollama ollama pull qwen2.5:7b
docker exec rag_ollama ollama pull nomic-embed-text

8.3 带 Gradio UI 的完整应用

"""app.py — 带 Web UI 的本地 RAG 应用"""
import gradio as gr
from rag_pipeline import BGEEmbedder, VectorStore, RAGEngine, ObsidianLoader
import os
 
VAULT_PATH = os.environ.get("VAULT_PATH", "/vault")
PERSIST_DIR = os.environ.get("INDEX_PATH", "/app/index/chroma_db")
LLM_MODEL = os.environ.get("LLM_MODEL", "qwen2.5:7b")
 
# 初始化(模块级别,只执行一次)
print("初始化 RAG 系统...")
embedder = BGEEmbedder("BAAI/bge-m3")
store = VectorStore(PERSIST_DIR, embedder)
engine = RAGEngine(store, llm_backend="ollama", model_name=LLM_MODEL)
print("RAG 系统就绪")
 
 
def chat(message: str, history: list, show_sources: bool) -> tuple[str, list]:
    result = engine.ask(message)
    answer = result["answer"]
    if show_sources and result["sources"]:
        answer += f"\n\n---\n**引用来源**:\n" + "\n".join(f"- {s}" for s in result["sources"])
    history.append((message, answer))
    return "", history
 
 
def reindex():
    loader = ObsidianLoader(VAULT_PATH)
    docs = loader.load_documents()
    store.add_documents(docs)
    return f"索引完成:共 {len(docs)} 个文档块,数据库中共 {store.collection.count()} 条记录"
 
 
with gr.Blocks(title="Personal Knowledge RAG") as demo:
    gr.Markdown("# 个人知识库问答系统")
 
    with gr.Row():
        chatbot = gr.Chatbot(height=500)
 
    with gr.Row():
        msg = gr.Textbox(placeholder="输入问题...", scale=4)
        show_src = gr.Checkbox(label="显示来源", value=True, scale=1)
 
    with gr.Row():
        submit_btn = gr.Button("提问", variant="primary")
        reindex_btn = gr.Button("重新索引知识库")
        reindex_status = gr.Textbox(label="索引状态", interactive=False)
 
    submit_btn.click(chat, [msg, chatbot, show_src], [msg, chatbot])
    msg.submit(chat, [msg, chatbot, show_src], [msg, chatbot])
    reindex_btn.click(reindex, outputs=reindex_status)
 
demo.launch(server_name="0.0.0.0", server_port=7860)

8.4 性能基准参考

硬件配置嵌入速度(BGE-M3)LLM 推理(Qwen2.5:7b)检索延迟(10万块)
M2 Mac (16GB)~50 chunks/s~25 tokens/s< 50ms
RTX 3090 (24GB)~200 chunks/s~60 tokens/s< 30ms
CPU Only (16核)~15 chunks/s~5 tokens/s< 100ms

首次索引 1000 篇笔记(约 5000 个 chunk)所需时间

  • M2 Mac:约 100 秒
  • RTX 3090:约 25 秒
  • CPU:约 330 秒

增量更新(只处理变更文件)通常在 10 秒以内完成。


总结

RAG 是目前将 LLM 与私有知识结合的最实用方案。本文覆盖的技术路径:

  1. 基础 Pipeline:Obsidian Markdown → BGE-M3 嵌入 → Chroma 存储 → 检索 → Qwen2.5 生成
  2. 质量提升:HyDE 假设文档 + Parent-Child 分块 + BGE Reranker 重排序
  3. 生产化:增量更新、置信度传递、无答案拒绝、Docker 部署

对于个人知识库场景,推荐起步配置:

嵌入模型:BAAI/bge-small-zh-v1.5(速度快,中文质量好)
向量库:Chroma(零配置,本地持久化)
LLM:Ollama + qwen2.5:7b(本地,隐私安全)
分块:RecursiveCharacterTextSplitter(chunk_size=500, overlap=60)

随着知识库增长(>10 万条记录),再迁移到 Qdrant + BGE-M3 + 重排器的生产级方案。