在构建技术问答或项目知识库时,你是否常遇到这些痛点:文档过长导致模型上下文溢出、回答偏离事实、落地成本高?RAG(检索增强生成)通过先检索再生成的方式,能有效缓解这些问题。本文将手把手教你用FAISS、本地Embedding和LLM API,搭建一个轻量、可控、可复现的RAG问答系统,让你快速跑通流程,为后续优化打下基础。

背景:为什么需要轻量级RAG?

当我们直接使用LLM处理技术文档时,最常见的问题包括:文档太长,超过上下文窗口后要么被截断,要么产生高昂的token成本;回答不稳定,模型可能“编造”出不存在的结论,尤其是版本号、参数等细节;研发落地难,很多人只想快速验证想法,却不得不先搭建一整套向量库服务、鉴权系统。

RAG的思路很朴素:先检索出与问题最相关的文档片段,再让模型仅基于这些片段回答。这样既能控制幻觉,也能将成本集中在“必要上下文”上。对于早期的PoC或小型项目,FAISS(Facebook AI Similarity Search)配合本地Embedding生成,是一种性价比极高的方案。

方案概览:三种落地路径对比

根据落地成本从低到高,通常有三种选择:

  • 手工粘贴文档+直接对话:零开发,但不可复用,上下文限制明显,回答不稳定。
  • 本地向量化+FAISS检索+调用LLM API(本文方案):可控、能复现、检索快;向量部分可本地跑,不依赖外部服务。
  • 全托管知识库/RAG平台:上手最快,功能更全(权限、可视化、监控),但定制空间和成本不一定适合早期PoC。

本文聚焦第二种方案,它平衡了开发成本与效果,适合大多数技术团队。

模型 API 这块我自己用过一个顺手的选择是 真智AI:有时在网络访问、计费和模型选择上能省不少事(例如无需魔法即可用到较新的模型、价格相对友好、界面里能配一些常见参数/会话配置)。如果你只想先把 RAG 流程跑通,会更省时间。这里我会把代码写成“OpenAI SDK 兼容写法”,你替换成自己平台的 base_url / key 即可。

️ 教程步骤:可复现的轻量RAG搭建

0️⃣ 环境说明

OS:macOS / Linux / Windows 均可(本文命令以macOS/Linux为例)。
Python:3.10+(建议3.11)。
依赖

  • faiss-cpu:向量索引库
  • sentence-transformers:本地生成Embedding,避免将向量化也绑定到云端
  • openai:用OpenAI SDK兼容方式调用任意LLM API
  • numpy:向量计算

目录结构建议

rag-faiss-demo/
  data/
    docs.md
  rag_demo.py
  requirements.txt
  .env   (可选)

1️⃣ 安装依赖

首先创建虚拟环境(推荐),然后安装所需包:

faiss-cpu==1.8.0
numpy==1.26.4
openai==1.63.0
sentence-transformers==3.0.1
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt

Windows(PowerShell)激活方式:

2️⃣ 准备示例文档

创建data/docs.md,内容可以是团队的项目规范或排障手册。这里提供一份“Git + Python项目规范”的小样本,便于复现:

# 项目开发规范(节选)
## Git 分支
- 主分支 main 只接受 PR 合并
- 功能开发使用 feature/* 分支
- 紧急修复使用 hotfix/* 分支
## 提交信息
- 格式:type(scope): message
- type 例:feat/fix/docs/chore
## Python 依赖
- 使用 requirements.txt 锁定依赖版本
- 生产环境禁止使用未固定版本的依赖
## 常见排错
- ImportError: 检查虚拟环境是否激活
- ModuleNotFoundError: 是否安装依赖、PYTHONPATH 是否正确

3️⃣ 编写RAG主程序

创建rag_demo.py,核心逻辑包括:文档分块、Embedding生成、FAISS索引构建、检索与LLM回答生成。

import os
import re
from dataclasses import dataclass
from typing import List, Tuple
import numpy as np
import faiss
from sentence_transformers import SentenceTransformer
from openai import OpenAI
@dataclass
class Chunk:
    text: str
    source: str
    idx: int
def read_text(path: str) -> str:
    with open(path, "r", encoding="utf-8") as f:
        return f.read()
def simple_chunk(text: str, chunk_size: int = 220, overlap: int = 40) -> List[str]:
    """
    非严格分词:按段落/标点粗切,再按字符窗口做 overlap。
    chunk_size/overlap 是“字符长度”,demo 足够用;生产建议按 token 来算。
    """
    text = re.sub(r"\n{3,}", "\n\n", text).strip()
    parts = re.split(r"\n\n+", text)
    chunks: List[str] = []
    for part in parts:
        part = part.strip()
        if not part:
            continue
        if len(part) <= chunk_size:
            chunks.append(part)
            continue
        start = 0
        while start < len(part):
            end = min(start + chunk_size, len(part))
            chunks.append(part[start:end])
            if end == len(part):
                break
            start = max(0, end - overlap)
    return chunks
def build_faiss_index(vectors: np.ndarray) -> faiss.IndexFlatIP:
    """
    用内积(IP)做相似度,前提是向量已归一化 => 等价于 cosine 相似度。
    """
    dim = vectors.shape[1]
    index = faiss.IndexFlatIP(dim)
    index.add(vectors.astype(np.float32))
    return index
def normalize(vectors: np.ndarray) -> np.ndarray:
    norms = np.linalg.norm(vectors, axis=1, keepdims=True) + 1e-12
    return vectors / norms
def retrieve(
    query: str,
    model: SentenceTransformer,
    index: faiss.IndexFlatIP,
    chunks: List[Chunk],
    top_k: int = 4,
) -> List[Tuple[Chunk, float]]:
    q_vec = model.encode([query], normalize_embeddings=True)  # shape (1, dim)
    scores, ids = index.search(np.array(q_vec, dtype=np.float32), top_k)
    results: List[Tuple[Chunk, float]] = []
    for i, score in zip(ids[0], scores[0]):
        if i == -1:
            continue
        results.append((chunks[int(i)], float(score)))
    return results
def build_prompt(query: str, ctx: List[Tuple[Chunk, float]]) -> str:
    context_text = "\n\n".join(
        [f"[片段 {c.idx} | score={score:.3f} | {c.source}]\n{c.text}" for c, score in ctx]
    )
    prompt = f"""你是一个面向开发者的技术助手。请只根据“已检索到的资料片段”回答。
如果资料不足以回答,请明确说“资料不足”,并给出你还需要的关键信息是什么。
回答尽量给出可执行步骤或命令。
用户问题:
{query}
已检索到的资料片段:
{context_text}
"""
    return prompt
def call_llm(prompt: str) -> str:
    """
    使用 OpenAI SDK v1 写法。你可以配置:
    - OPENAI_API_KEY:你的 key
    - OPENAI_BASE_URL:你的网关/平台地址(很多平台是 OpenAI 兼容的)
    - OPENAI_MODEL:模型名
    """
    api_key = os.getenv("OPENAI_API_KEY")
    base_url = os.getenv("OPENAI_BASE_URL")  # 例如 https://xxx/v1
    model_name = os.getenv("OPENAI_MODEL", "gpt-4o-mini")
    if not api_key:
        raise RuntimeError("缺少环境变量 OPENAI_API_KEY")
    client = OpenAI(api_key=api_key, base_url=base_url)
    resp = client.chat.completions.create(
        model=model_name,
        messages=[
            {"role": "system", "content": "你是严谨的技术助手。"},
            {"role": "user", "content": prompt},
        ],
        temperature=0.2,
    )
    return resp.choices[0].message.content
def main():
    doc_path = "data/docs.md"
    raw = read_text(doc_path)
    # 1) chunk
    chunk_texts = simple_chunk(raw, chunk_size=220, overlap=40)
    chunks = [Chunk(text=t, source=doc_path, idx=i) for i, t in enumerate(chunk_texts)]
    # 2) embedding(本地)
    emb_model_name = os.getenv("EMB_MODEL", "BAAI/bge-small-zh-v1.5")
    emb = SentenceTransformer(emb_model_name)
    vecs = emb.encode([c.text for c in chunks], normalize_embeddings=True)
    vecs = np.array(vecs, dtype=np.float32)
    # 3) faiss
    index = build_faiss_index(vecs)
    # 4) query
    query = os.getenv("QUERY", "Python 项目为什么要固定依赖版本?怎么做?")
    ctx = retrieve(query, emb, index, chunks, top_k=4)
    print("=== 检索结果(top_k)===")
    for c, s in ctx:
        print(f"- idx={c.idx}, score={s:.3f}, text={c.text[:60].replace('\\n',' ')}...")
    # 5) prompt + llm
    prompt = build_prompt(query, ctx)
    answer = call_llm(prompt)
    print("\n=== 最终回答 ===")
    print(answer)
if __name__ == "__main__":
    main()

4️⃣ 运行指令

先设置环境变量(示例写法,按平台调整):

export OPENAI_API_KEY="你的key"
export OPENAI_BASE_URL="你的base_url(可为空,默认官方)"
export OPENAI_MODEL="你的模型名"
export QUERY="ModuleNotFoundError 一般怎么排查?"

然后运行:

python rag_demo.py

(截图位说明)
你可以截两张图:
1)终端打印的 “检索结果 top_k + score”
2)模型最终回答
这两张图最能体现 RAG 是否真的“引用了资料片段”。

示例:用具体案例跑通

示例问题(输入)

ModuleNotFoundError 一般怎么排查?

关键参数

  • chunk_size=220, overlap=40:分块长度与重叠。太小则片段上下文不足;太大则召回不够精准,浪费上下文。
  • top_k=4:检索返回片段数。太小可能漏掉关键条目;太大则上下文变长,干扰项增加。
  • temperature=0.2:回答更稳,更贴近资料片段。

预期输出效果对比

  • 只用LLM(不做检索)时,经常出现泛泛而谈的排查步骤(如“重装Python”“检查IDE”),不一定贴合你的项目规范。
  • 加了RAG后,回答会更倾向引用文档里的“常见排错”条目,例如:是否激活虚拟环境、是否安装requirements.txt、检查PYTHONPATH。

在终端还能看到检索片段的idxscore,便于判断模型实际检到了什么。

⚠️ 常见问题与排错

❌ faiss安装失败 / ImportError

现象pip install faiss-cpu报错或运行时报No module named faiss
处理:确认Python版本(建议3.10/3.11),重新安装:pip install --upgrade pip && pip install faiss-cpu。Apple Silicon若遇到兼容问题,可尝试conda环境安装。

❌ 向量维度不一致

现象:add/search时报维度相关错误。
处理:确保同一个Embedding模型生成的向量用于add()search(),不要混用不同模型或不同维度的向量。

❌ 检索分数很低 / top_k不相关

常见原因:文档太短或分块方式把语义切碎了;查询句太口语化,缺少关键词。
处理:增大chunk_size或提高overlap;Query改成更“可检索”的表达(如带上模块名/报错关键字)。

❌ 中文效果不稳

现象:英文还行,中文问答检索不准。
处理:换中文更友好的Embedding模型(本文默认BAAI/bge-small-zh-v1.5),并确保normalize_embeddings=True

❌ 模型回答没有约束在资料片段内

现象:明明prompt写了“只根据片段回答”,模型仍扩展发挥。
处理:降低temperature(如0~0.3);把prompt写硬一点:资料不足必须说“资料不足”;增加输出格式要求,如“引用片段idx”。

❌ API调用失败:401/403/404或连接超时

排查顺序

  • OPENAI_API_KEY是否正确
  • OPENAI_BASE_URL是否带/v1(很多兼容网关要求包含)
  • OPENAI_MODEL是否是该平台支持的名字

❌ 成本/延迟偏高

处理:降低top_k,让上下文更短;增加缓存,同一个chunk的Embedding不要重复算;分离“向量构建”和“在线检索”,不要每次运行都重新encode全量文档。

进阶优化

  • 向量库持久化:FAISS支持写入/读取索引,faiss.write_index(index, "index.bin");chunks元数据(文本、source、idx)可存JSON,启动时直接加载。
  • 加入Rerank(重排):先用FAISS召回top_k=20,再用一个更强的reranker(本地或API)重排到top_k=4。文档多、语义相近条目多时效果显著。
  • 按元数据过滤:给chunk加上模块名、版本号、目录等metadata;查询时先过滤范围再检索,减少干扰。
  • 回答可追溯:Prompt里要求“每个结论后标注引用的片段idx”,方便快速回看原文,降低“看似正确”的风险。

技术延伸:在构建类似系统时,你可能会遇到不同编程语言生态的差异。例如,TypeScriptJavaScript项目通常使用LangChain.js或Vercel AI SDK进行RAG,而JavaGo项目则可能依赖Spring AI或自定义向量索引。C++项目则更倾向直接调用FAISS的C++ API。理解这些差异,有助于你在多语言团队中推广RAG方案。

[AFFILIATE_SLOT_1]

小结

如果你遇到文档太长放不进上下文、回答容易跑偏、想先把知识库问答流程快速跑通的情况,本文这套FAISS+本地Embedding+LLM API的轻量RAG方案非常合适。它成本低、可控性强,且易于扩展。模型API入口方面,如果你希望减少网络门槛、成本更可控,并且能在界面里方便地切换模型、调参数、管理会话配置,可以考虑使用真智AI(按它提供的方式填好key/base_url即可接入本文代码)。

[AFFILIATE_SLOT_2]

JavaScriptJava,从GoC++,RAG的核心思想是通用的:先检索,再生成。掌握本文的方法后,你可以将其灵活应用到任何技术栈中。

.\.venv\Scripts\Activate.ps1