目的
社内アプリのソースコードを対象としたAI(RAG)を最速(30分以内)で構築します。- 質問→ベクトル検索→LLM→回答(+シーケンス図作成)
前提条件
- VM(Ubuntu 24.04)
- Docker が動作
- 環境変数
OPENAI_API_KEY
を.env
に設定
echo OPENAI_API_KEY=sk-… > ~/.env
1. Python 仮想環境&ライブラリ整備
python3 -m venv venv && source venv/bin/activate && pip install chromadb==0.6.3 langchain-chroma==0.2.3 langchain-openai==0.3.14 fastapi uvicorn python-dotenv streamlit requests
2. ベクトルインデックス生成
ソースコードリポジトリをファイル走査して 1,000~1,200文字チャンクに分割し、OpenAI で埋め込み→Chroma に保存。対象が数千ファイルであれば、実行時間は十数分、コストは
$1.5
程度でした。
実行例:
python create_index.py --repo ~/repo1 --persist ~/chroma-data --col repo1
#!/usr/bin/env python3 """ create_index.py — リポジトリを Chroma インデックス化 """ import argparse,os,pathlib,shutil,sys from dotenv import load_dotenv import openai from langchain.text_splitter import RecursiveCharacterTextSplitter,Language from langchain.docstore.document import Document from langchain_openai import OpenAIEmbeddings from langchain_chroma import Chroma p=argparse.ArgumentParser() p.add_argument('--repo',required=True) p.add_argument('--persist',default='~/chroma-data') p.add_argument('--col',default='repo1') p.add_argument('--chunk',type=int,default=1200) p.add_argument('--overlap',type=int,default=100) args=p.parse_args() ROOT=pathlib.Path(args.repo).expanduser().resolve() PERSIST=pathlib.Path(args.persist).expanduser().resolve()/args.col load_dotenv() openai.api_key=os.getenv('OPENAI_API_KEY') or sys.exit('Missing API key') EXTS={'.php':Language.PHP,'.ts':Language.TS,'.js':Language.JS, '.vue':Language.JS,'.twig':Language.HTML, '.scss':Language.CPP,'.css':Language.CPP} files=[f for f in ROOT.rglob('*') if f.suffix in EXTS] docs=[] for f in files: try: txt=f.read_text(errors='ignore') except: continue docs.append(Document(page_content=txt,metadata={'path':str(f.relative_to(ROOT))})) splits=[] for d in docs: lang=EXTS[pathlib.Path(d.metadata['path']).suffix] splits+=RecursiveCharacterTextSplitter.from_language( language=lang,chunk_size=args.chunk,chunk_overlap=args.overlap ).split_documents([d]) if PERSIST.exists(): shutil.rmtree(PERSIST) emb=OpenAIEmbeddings(model='text-embedding-3-large') Chroma.from_documents(documents=splits,embedding=emb, persist_directory=str(PERSIST),collection_name=args.col) print('Indexed at',PERSIST)
3. 質問応答 API 整備
FastAPI で/ask
(QA)と /render_mermaid
(Mermaid→PNG)を提供。クエリパラメータでリポジトリ(
repo1
/repo2
)とモデル(gpt-4.1
/gpt-4.1-mini
/gpt-4o-mini
/o4-mini
)を切替可能です。
起動例:
uvicorn qa_api:app --host 127.0.0.1 --port 8600 --log-level debug
#!/usr/bin/env python3 """ qa_api.py — QA(+Mermaid→PNG) API(repo/model切替可) """ import pathlib,uuid,subprocess,uvicorn from fastapi import FastAPI,HTTPException,Query from fastapi.responses import FileResponse from pydantic import BaseModel from dotenv import load_dotenv from langchain_openai import OpenAIEmbeddings,ChatOpenAI from langchain_chroma import Chroma from langchain.chains import RetrievalQA from langchain.prompts import PromptTemplate PERSIST_BASE=pathlib.Path.home()/'chroma-data' REPOS=['repo1','repo2'] MODELS=['gpt-4.1','gpt-4.1-mini','gpt-4o-mini','o4-mini'] TMP=pathlib.Path('/tmp/mermaid_pngs');TMP.mkdir(mode=0o777,parents=True,exist_ok=True) DOCKER_IMG='minlag/mermaid-cli';SCALE='3' EMB='text-embedding-3-large';TOP_K=8 load_dotenv() emb=OpenAIEmbeddings(model=EMB) RETRIEVERS={r:Chroma(persist_directory=str(PERSIST_BASE/r), collection_name=r,embedding_function=emb ).as_retriever(search_kwargs={'k':TOP_K}) for r in REPOS} prompt=PromptTemplate(input_variables=['context','question'],template=""" You are a code expert. Context: {context} Question: {question} 1. Answer concisely. 2. If sequence diagram is requested, include a Mermaid sequenceDiagram block. 3. Otherwise, no Mermaid. """) app=FastAPI() class QAReq(BaseModel):question:str @app.post('/ask') def ask(req:QAReq, repo:str=Query('repo1',enum=REPOS), model:str=Query('gpt-4.1',enum=MODELS)): retr=RETRIEVERS.get(repo) if not retr: raise HTTPException(400,'Unknown repo') if model in ['gpt-4.1','gpt-4.1-mini','o4-mini']: llm=ChatOpenAI(model_name=model) else: llm=ChatOpenAI(model_name=model,temperature=0.0) chain=RetrievalQA.from_chain_type(llm=llm,chain_type='stuff', retriever=retr,chain_type_kwargs={'prompt':prompt}, return_source_documents=True) res=chain(req.question) return {'answer':res['result'], 'sources':[d.metadata['path'] for d in res['source_documents']]} class RenderReq(BaseModel):mermaid:str @app.post('/render_mermaid') def render(req:RenderReq): k=uuid.uuid4().hex m=TMP/f'{k}.mmd';p=TMP/f'{k}.png' m.write_text(req.mermaid,encoding='utf-8') try: subprocess.run(['docker','run','--rm','-v', f'{TMP.absolute()}:/data',DOCKER_IMG, '-i',f'/data/{k}.mmd','-o',f'/data/{k}.png', '-s',SCALE],check=True,stdout=subprocess.PIPE, stderr=subprocess.PIPE) except subprocess.CalledProcessError as e: err=(e.stderr or e.stdout).decode().strip() raise HTTPException(500,detail=f'Mermaid error: {err}') return FileResponse(str(p),media_type='image/png') @app.get('/health') def health(): return {'status':'ok'} if __name__=='__main__': uvicorn.run(app,host='0.0.0.0',port=8600)
4. Web UI 整備
起動例:streamlit run ui.py --server.port 8501
#!/usr/bin/env python3 """ ui.py — Streamlit チャット UI(repo/model選択+PNG表示) """ import os,re,requests,streamlit as st from dotenv import load_dotenv st.set_page_config(page_title='コードアシスタント',layout='wide') load_dotenv() API_ASK=os.getenv('API_ASK','http://localhost:8600/ask') API_RDR=os.getenv('API_RENDER','http://localhost:8600/render_mermaid') repo=st.sidebar.selectbox('リポジトリ',['repo1','repo2'],index=0) model=st.sidebar.selectbox('モデル',['gpt-4.1','gpt-4.1-mini','gpt-4o-mini','o4-mini'],index=0) st.sidebar.markdown(f'ask: `{API_ASK}`') st.sidebar.markdown(f'render: `{API_RDR}`') MERMAID_RE=re.compile(r'```mermaid\n(.*?)```',re.S) st.title('🛠️ コード QA') if 'chat' not in st.session_state: st.session_state.chat=[] for r,m in st.session_state.chat: st.chat_message(r).write(m) q=st.chat_input('質問を入力...') if q: st.session_state.chat.append(('user',q));st.chat_message('user').write(q) with st.spinner('回答中…'): res=requests.post(f"{API_ASK}?repo={repo}&model={model}", json={'question':q}).json() ans,sources=res['answer'],res['sources'] st.session_state.chat.append(('assistant',ans)) with st.chat_message('assistant'): parts=MERMAID_RE.split(ans) for i,p in enumerate(parts): if i%2==0 and p.strip(): st.markdown(p) if i%2==1: img=requests.post(API_RDR,json={'mermaid':p}).content st.image(img,use_container_width=True) with st.expander('Mermaid ソース'): st.code(p) if sources: with st.expander(f'{len(sources)} 件の参照コード'): for s in sources: st.markdown(f'- `{s}`')
5. Nginx 設定(抜粋)
location /api/ { proxy_pass http://127.0.0.1:8600/; } location / { proxy_pass http://127.0.0.1:8501; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; }